Type-check your JavaScript with JSDoc

JSDoc refers to JavaScript documentation through comment blocks, it is similar to the JavaDoc. With it, it is possible to type-check your JavaScript code and avoid setting up a build/compilation step in your project. This method still requires TypeScript, but only for developing, not to ship your code.

Why would anyone want to use JSDoc instead of TypeScript for type-checking?

If you're building a frontend application using a library like React, you will probably use a tool such as Webpack (or Vite) to bundle/compile your code into JavaScript that browsers can understand. In that case you might as well use TypeScript because you already have a build step. Though, if you think the Hot Reloading or the build step are too slow, JSDoc could still be of help.

Now if you're building a library, then JSDoc becomes really attractive. It allows you to document your library and type-check your code without requiring you to have a build step. You can focus on coding then publish your library as is.

Let's dive into an example to help you understand how type-checking with JSDoc works.

/**
* Iterates over elements of array and invokes function for each element
* @template T the type of the array's elements
* @param {T[]} arr the array to iterate over
* @param {(element: T) => void} fn the function invoked per iteration
*/

function forEach(arr, fn) {
for (let i = 0; i < arr.length; i++) {
fn(arr[i]);
}
}

// Self-executing function
(() => {
const fruits = ["apple", "orange", "pear", "kiwi", "banana", "pineapple"];
forEach(fruits, (fruit) => {
console.log(fruit);
});
})();

If you copy-paste this code in a file then execute it with Node.js you should get the following output in your terminal

apple
orange
pear
kiwi
banana
pineapple

The example function aims to mimic the array's built-in forEach function. It receives an array with elements of any type and the second parameter is a function that will be invoked on each element of the array, that function will receive the specific element on each iteration.

The self-executing function calls our custom forEach function with an array of strings (fruit names) and a function that receives a string (fruit name) and logs it.

Let's check the JSDoc of our forEach function.

/**
* Iterates over elements of array and invokes function for each element
* @template T the type of the array's elements
* @param {T[]} arr the array to iterate over
* @param {(element: T) => void} fn the function invoked per iteration
*/

JSDoc usually starts with /** and ends with */. Each line in-between starts with a *.

The first part is simply the description of our function, depending on your IDE you should be able to see that description by passing your cursor on the function.

The @template tag is a bit more complicated. It's the equivalent of a generic type in TypeScript. I will explain why we need it after. But to use a @template you just need to give it a name, it can be anything but usually starts with an uppercase letter.

The @param tag allows to specify the type of a parameter of the function. You need to specify the type between curly brackets {} then indicate the name of the parameter.

The first parameter @param {T[]} arr indicates that our parameter arr is an array of T. T is our generic type that we defined with the @template tag.

The second parameter @param {(element: T) => void} fn indicates that our parameter fn is a function that has one parameter of type T and returns nothing (void). Again we make use of the generic type T defined in the @template tag.

Because our function can iterate over an array with elements of any type, we don't know in advance the type of the elements, so we need to use a generic type instead. This allows us to specify that the elements of the array are of type T and that the function invoked on each iteration will receive a parameter of type T, the same type as the elements of the array.

In practice, if we pass an array of strings, T will be of type string, if we pass an array of numbers, T will be of type number. When we pass the second parameter, the function's parameter will be the type defined by T.

Generic types is not an easy topic and not the focus of this post so I won't be giving more details on the matter, if you're interested in learning about generics, I would advise checking the TypeScripts documentation about it.

Ok, That's all nice but what if you need to use more complicated types. There are two ways I can think of.

First you can use the @typedef tag

/** @typedef {
* {
* a: string;
* b: number;
* c: { d: boolean; };
* }
* } Example */

You can then use the Example type in your JSDoc annotations, as long as they are in the same file.

/** @type {Example} */
let example;

If you need to re-use your type definition in multiple files, I would advise using the other approach; .d.ts files. Yes it is TypeScript, but only containing types, so no need for a compilation step.

Let's move our Example type to a Example.d.ts file

export type Example = {
a: string;
b: number;
c: { d: boolean };
};

Now you can import it directly in your JSDoc annotations like so

/** @type {import("./Example").Example} */
let example;

If you need to use a type from an external library, you can use the same syntax. For example if you need to use types from the library express

/**
* @param {import("express").Request} req
* @param {import("express").Response} res
*/

function handler(req, res) {
// ...
}

There are more JSDoc tags supported by TypeScript, I invite you to check them out on the TypeScript JSDoc Reference page.

Now that we saw how to use JSDoc to define types let's see how to take advantage of it when coding in our IDE. I'm using Visual Studio Code (VSCode) and I can't confirm that this method will work on other IDEs, but it just might.

By default, VSCode is running TypeScript behind the scenes when it finds a tsconfig.json or jsconfig.json file in your project. To take advantage of this, you could create a jsconfig.json file at the root of your project with the following content

{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true
}
}

There are other options available, but the ones in the example are probably good enough to get you started on a new project.

Check out these links for more details on how to setup your jsconfig.json

Now your VSCode should warn you when you're doing something wrong (text will be underlined in red).

You can also type-check "manually" by installing TypeScript globally or in your project

npm install --save-dev typescript

Then run it with

npx tsc -p jsconfig.json

It is useful if you want to type-check your code before committing (using a pre-commit hook) or during a CI (continuous integration) step.

I think I've covered the basics to get you started with JSDoc and type-checking. I personally really like that approach and have started using it more and more.

If you wonder if this JSDoc type-checking method is used in any popular projects, check out Preact's repository.