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:
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:
Now, completing the object will clear the error:
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'
};
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
.
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.
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.