Skip to main content

Build-free type annotations with JSDoc and TypeScript

I've used TypeScript in various client projects, but sometimes got bogged down with migrating to .ts files and the tooling. After Svelte switched to using JSDoc with TypeScript in the spring of last year, it caught my attention and motivated me to try it out in my own projects.

My solo projects are entirely in vanilla JS. I like the simplicity, direct learning with exposure to browser APIs and web standards, and adaptability it gives me so I can help clients in various frameworks with better fundamental skills.

One of my favorite benefits of working this way is a build-free setup with no production dependencies and normal .js files. As a result, the build setup and file extension for TypeScript have been a deal breaker for my personal work. If your project already has a build step, then just using TypeScript can be great. But in my work, using JSDoc with TypeScript unlocked all the value of type annotations without the unwanted overhead.

Using JSDoc

On it's own, JSDoc is a documentation generator for JavaScript that uses comments to describe your code with various tags that use the @ syntax.

The tags we're most interested in for type annotations are @type, @typedef, @property, @params, and @returns.

For example, when creating a new element and assigning it to a variable, we can document the @type as HTMLElement:

/** @type {HTMLElement} */
const headingElement = document.createElement("h1");

Or, we can document a function's signature with its parameter and return types using the @param and @returns tags:

/**
 * @param {Node} node
 * @returns {String}
 */
function getNodeName(node) {
	return node.nodeName.toLowerCase();
}

Occasionally, it's necessary to cast a type so it respects a function's signature and expected types, such as specifying an Event type as a KeyboardEvent. This can be done with the inline comment syntax, @type tag, and wrapping the expression you want to cast in parentheses:

addEventListener("keydown", (event) => {
	handleKeydown(/** @type {KeyboardEvent} */ (event));
});

The last thing I commonly do is create a custom type definition for objects that are used between functions. This uses the @typedef and @property tags to document the structure of an object. Here's an example of creating a custom type for menu option objects that contain properties for a display name, action, and keyboard shortcut:

/**
 * @typedef {Object} Option
 * @property {String} displayName
 * @property {String} action
 * @property {String} [shortcut=""]
 */

This example also demonstrates the optional syntax using square brackets and an optional default value with the equals sign.

With this type definition, we can use this custom type elsewhere in our annotations:

/**
 * @param {Option} option
 * ...
 */

The main pushback I find with JSDoc that leads to "Just use TypeScript!" responses is the syntax is too verbose. I personally don't mind the syntax. It's simple, readable, and with snippets takes little effort to author.

Most JSDoc examples include a description in the first line of the comment, but these add little value if you have well named, readable code. In fact, I treat descriptions much like code comments in general: an anti-pattern that indicates there are code quality issues. There are rare exceptions, such as explaining why something obscure is needed due to a browser bug, but my standard is to omit descriptions.

VSCode has strong support for JSDoc, including default snippets for both the multi-line and single line comments, which start with a forward slash and two asterisks: /**. You can expand multi-line comments with the Enter key, and VSCode will do a decent job of filling out the parameter and return types for you. You can expand single-line comments with the Tab key, but VSCode can't infer the tags or types in this case.

As a standalone tool, JSDoc provides a lot of value with standardized documentation throughout a codebase. However, it's just static documentation and doesn't provide any feedback while coding. For that, we'll add TypeScript into the mix for continuous type checking.

Using JSDoc with TypeScript

The first thing we need to do to leverage TypeScript is install it as a dev dependency:

npm i typescript -D

Next, we need a configuration file, such as jsconfig.json, with our desired settings:

{
	"compilerOptions": {
		"allowJs": true,
		"checkJs": true,
		"strict": true,
		"noEmit": true
	},
	"include": ["src"]
}

With TypeScript set up, it'll immediately provide syntax highlighting for any type issues. If you don't already have type annotations, you'll find red squiggles throughout your codebase. If you have existing type annotations that are inaccurate or function calls that aren't respecting the proper types, these will be surfaced as well.

This continuously reinforces standards to annotate your code, helps prevent accidental type coercion, and makes your code more resilient by accounting for edge cases from unexpected types.

These edge cases often go completely unaccounted for and create bugs that are hard to track down or replicate. Test-Driven Development (TDD) helps reduce these bugs, but these types of tests are lower value, tedious to write, and difficult to exhaustively predict for every function and variable.

How I use tests and type annotations together

As an example, let's enforce proper types in a getNodeName function:

function getNodeName(node) {
	return node.nodeName.toLowerCase();
}

With tests, we should assert the function throws an error if the type of node passed into the function is not Node. We should also test that the returned value is a string:

describe("getNodeName", () => {
	it("throws an error if the provided node is not a type of Node") {
		expect(() => {
			getNodeName("string");
		}).to.throw(Error, "node has incorrect type of [object String]");
	}

	it("returns the node name as a string") {
		expect(() => {
			const testDivElement = document.createElement("div");
			const returnValue = getNodeName(testDivElement);

			expect(typeof returnValue).to.equal("string");
		})
	}
});

To make these tests pass, we'll need to improve our function:

function getNodeName(node) {
	if (!node.nodeName) {
		throw new Error(
			`node has incorrect type of ${Object.prototype.toString.call(
				node
			)}`
		);
	}

	return node.nodeName.toLowerCase();
}

I can be persuaded that even with type checking, these tests are valuable. But I write tests to get continuous feedback about the code I'm writing. JSDoc with TypeScript provide that feedback, so I'm personally comfortable skipping these types of tests in my own projects. This is a decision I might revisit in the future.

So instead, or in addition to these tests, we can add type annotations to our function:

/**
 * @param {Node} node
 * @returns {String}
 */
function getNodeName(node) {
	if (!node.nodeName) {
		throw new Error(
			`Cannot get node name of "${node}" with incorrect type of ${Object.prototype.toString.call(
				node
			)}`
		);
	}

	return node.nodeName.toLowerCase();
}

To finish our function, it's still important to assert our function guards against null or undefined values. Let's write a test for that:

describe("getNodeName", () => {
	it("throws an error if no node is provided") {
		expect(() => {
			getNodeName();
		}).to.throw(Error, "node is undefined");
	}

	/* ... */
});

And we add the guard to our function:

/**
 * @param {Node} node
 * @returns {String}
 */
function getNodeName(node) {
	if (!node) {
		throw new Error("node is undefined")
	}

	/* ... */
}

A helpful snippet for type guards

One of the more common ways TypeScript makes code more resilient is in preventing null or undefined values from being operated on in subsequent steps.

I typically handle this with if statements that act as type guards. Here's an example of two type guards to prevent null and undefined values from causing errors later in the function:

/**
 * @returns {String}
 */
function getMenuActiveDescendantId() {
	/** @type {HTMLElement|null} */
	const docMenu = document.querySelector("#menu");
	if (!docMenu) {
		throw new Error("docMenu is null");
	}

	/** @type {String|undefined} */
	const activeDescendantId = docMenu.getAttribute("aria-activedescendant");
	if (!activeDescendantId) {
		throw new Error("activeDescendantId is undefined);
	}

	return activeDescendantId;
}

To make repeatedly typing this easier, I have a simple VSCode snippet that I invoke with nil followed by the Tab key:

"Throw error if null": {
	"prefix": "nil",
	"body": ["if (!$1) {", "  throw new Error(\"$1 is $2\");", "}"]
}

Wrapping up

I've been using JSDoc with TypeScript for months now and feel it's the perfect balance for my personal work. The nice thing is these skills are transferable to using TypeScript directly by simply adapting the syntax, making my transition to client work seamless.

I'm really excited for the native type annotations proposal which is currently Stage 1. I hope we end up with a nice syntax similar to the optional types I use in Python or in GDScript (a Python-like language used in the Godot open source video game engine).

If you implement JSDoc with TypeScript, be sure to reference the JSDoc documentation and especially the TypeScript JSDoc reference, which I find even more useful.