TypeScript, Minus TypeScript

Avatar of Caleb Williams
Caleb Williams on (Updated on )

Unless you’ve been hiding under a rock the last several years (and let’s face it, hiding under a rock sometimes feels like the right thing to do), you’ve probably heard of and likely used TypeScript. TypeScript is a syntactical superset of JavaScript that adds — as its name suggests — typing to the web’s favorite scripting language.

TypeScript is incredibly powerful, but is often difficult to read for beginners and carries the overhead of needing a compilation step before it can run in a browser due to the extra syntax that isn’t valid JavaScript. For many projects this isn’t a problem, but for others this might get in the way of getting work done. Fortunately the TypeScript team has enabled a way to type check vanilla JavaScript using JSDoc.

Setting up a new project

To get TypeScript up and running in a new project, you’ll need NodeJS and npm. Let’s start by creating a new project and running npm init. For the purposes of this article, we are going to be using VShttps://code.visualstudio.comCode as our code editor. Once everything is set up, we’ll need to install TypeScript:

npm i -D typescript

Once that install is done, we need to tell TypeScript what to do with our code, so let’s create a new file called tsconfig.json and add this:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"],
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "esModuleInterop": true
  },
  "include": [ "script", "test" ],
  "exclude": [ "node_modules" ]
}

For our purposes, the important lines of this config file are the allowJs and checkJs options, which are both set to true. These tell TypeScript that we want it to evaluate our JavaScript code. We’ve also told TypeScript to check all files inside of a /script directory, so let’s create that and a new file in it called index.js.

A simple example

Inside our newly-created JavaScript file, let’s make a simple addition function that takes two parameters and adds them together:

function add(x, y) {
  return x + y;
}

Fairly simple, right? add(4, 2) will return 6, but because JavaScript is dynamically-typed you could also call add with a string and a number and get some potentially unexpected results:

add('4', 2); // returns '42'

That’s less than ideal. Fortunately, we can add some JSDoc annotations to our function to tell users how we expect it to work:

/**
 * Add two numbers together
 * @param {number} x
 * @param {number} y
 * @return {number}
 */
function add(x, y) {
  return x + y;
}

We’ve changed nothing about our code; we’ve simply added a comment to tell users how the function is meant to be used and what value should be expected to return. We’ve done this by utilizing JSDoc’s @param and @return annotations with types set in curly braces ({}).

Trying to run our incorrect snippet from before throws an error in VS Code:

TypeScript evaluates that a call to add is incorrect if one of the arguments is a string.

In the example above, TypeScript is reading our comment and checking it for us. In actual TypeScript, our function now is equivalent of writing:

/**
 * Add two numbers together
 */
function add(x: number, y: number): number {
  return x + y;
}

Just like we used the number type, we have access to dozens of built-in types with JSDoc, including string, object, Array as well as plenty of others, like HTMLElement, MutationRecord and more.

One added benefit of using JSDoc annotations over TypeScript’s proprietary syntax is that it provides developers an opportunity to provide additional metadata around arguments or type definitions by providing those inline (hopefully encouraging positive habits of self-documenting our code).

We can also tell TypeScript that instances of certain objects might have expectations. A WeakMap, for instance, is a built-in JavaScript object that creates a mapping between any object and any other piece of data. This second piece of data can be anything by default, but if we want our WeakMap instance to only take a string as the value, we can tell TypeScript what we want:

/** @type {WeakMap<object>, string} */
const metadata = new WeakMap();


const object = {};
const otherObject = {};


metadata.set(object, 42);
metadata.set(otherObject, 'Hello world');

This throws an error when we try to set our data to 42 because it is not a string.

Defining our own types

Just like TypeScript, JSDoc allows us to define  and work with our own types. Let’s create a new type called Person that has name, age and hobby properties. Here’s how that looks in TypeScript:

interface Person {
  name: string;
  age: number;
  hobby?: string;
}

In JSDoc, our type would be the following:

/**
 * @typedef Person
 * @property {string} name - The person's name
 * @property {number} age - The person's age
 * @property {string} [hobby] - An optional hobby
 */

We can use the @typedef tag to define our type’s name. Let’s define an interface called Person with required  name (a string)) and age (a number) properties, plus a third, optional property called hobby (a string). To define these properties, we use @property (or the shorthand @prop key) inside our comment.

When we choose to apply the Person type to a new object using the @type comment, we get type checking and autocomplete when writing our code. Not only that, we’ll also be told when our object doesn’t adhere to the contract we’ve defined in our file:

Screenshot of an example of TypeScript throwing an error on our vanilla JavaScript object

Now, completing the object will clear the error:

Our object now adheres to the Person interface defined above

Sometimes, however, we don’t want a full-fledged object for a type. For example, we might want to provide a limited set of possible options. In this case, we can take advantage of something called a union type:

/**
 * @typedef {'cat'|'dog'|'fish'} Pet
 */


/**
 * @typedef Person
 * @property {string} name - The person's name
 * @property {number} age - The person's age
 * @property {string} [hobby] - An optional hobby
 * @property {Pet} [pet] - The person's pet
 */

In this example, we have defined a union type called Pet that can be any of the possible options of 'cat', 'dog' or 'fish'. Any other animals in our area are not allowed as pets, so if caleb above tried to adopt a 'kangaroo' into his household, we would get an error:

/** @type {Person} */
const caleb = {
  name: 'Caleb Williams',
  age: 33,
  hobby: 'Running',
  pet: 'kangaroo'
};
Screenshot of an an example illustrating that kangaroo is not an allowed pet type

This same technique can be utilized to mix various types in a function:

/**
 * @typedef {'lizard'|'bird'|'spider'} ExoticPet
 */


/**
 * @typedef Person
 * @property {string} name - The person's name
 * @property {number} age - The person's age
 * @property {string} [hobby] - An optional hobby
 * @property {Pet|ExoticPet} [pet] - The person's pet
 */

Now our person type can have either a Pet or an ExoticPet.

Working with generics

There could be times when we don’t want hard and fast types, but a little more flexibility while still writing consistent, strongly-typed code. Enter generic types. The classic example of a generic function is the identity function, which takes an argument and returns it back to the user. In TypeScript, that looks like this:

function identity<T>(target: T): T {
  return target;
}

Here, we are defining a new generic type (T) and telling the computer and our users that the function will return a value that shares a type with whatever the argument target is. This way, we can still pass in a number or a string or an HTMLElement and have the assurance that the returned value is also of that same type.

The same thing is possible using the JSDoc notation using the @template annotation:

/**
 * @template T
 * @param {T} target
 * @return {T}
 */
function identity(target) {
  return x;
}

Generics are a complex topic, but for more detailed documentation on how to utilize them in JSDoc, including examples, you can read the Google Closure Compiler page on the topic.

Type casting

While strong typing is often very helpful, you may find that TypeScript’s built-in expectations don’t quite work for your use case. In that sort of instance, we might need to cast an object to a new type. One instance of when this might be necessary is when working with event listeners.

In TypeScript, all event listeners take a function as a callback where the first argument is an object of type Event, which has a property, target, that is an EventTarget. This is the correct type per the DOM standard, but oftentimes the bit of information we want out of the event’s target doesn’t exist on EventTarget — such as the value property that exists on HTMLInputElement.prototype. That makes the following code invalid:

document.querySelector('input').addEventListener(event => {
  console.log(event.target.value);
};

TypeScript will complain that the property value doesn’t exist on EventTarget even though we, as developers, know fully well that an <input> does have a value.

A screenshot showing that value doesn’t exist on type EventTarget

In order for us to tell TypeScript that we know event.target will be an HTMLInputElement, we must cast the object’s type:

document.getElementById('input').addEventListener('input', event => {
  console.log(/** @type {HTMLInputElement} */(event.target).value);
});

Wrapping event.target in parenthesis will set it apart from the call to value. Adding the type before the parenthesis will tell TypeScript we mean that the event.target is something different than what it ordinarily expects.

Screenshot of a valid example of type casting in VS Code.

And if a particular object is being problematic, we can always tell TypeScript an object is @type {any} to ignore error messages, although this is generally considered bad practice depsite being useful in a pinch.

Wrapping up

TypeScript is an incredibly powerful tool that many developers are using to streamline their workflow around consistent code standards. While most applications will utilize the built-in compiler, some projects might decide that the extra syntax that TypeScript provides gets in the way. Or perhaps they just feel more comfortable sticking to standards rather than being tied to an expanded syntax. In those cases, developers can still get the benefits of utilizing TypeScript’s type system even while writing vanilla JavaScript.