JSDoc as an alternative TypeScript syntax
As web development has embraced static typing during the past decade, TypeScript has become the default language of choice. I think this is great—I love working with TypeScript!
But what if you can’t use TypeScript? You may encounter circumstances where you need to work in plain JavaScript, be it tooling constraints or a team member who does not like static typing.
Under these circumstances, look to JSDoc for salvation:
/*** @param {number} a* @param {number} b* @returns {number}*/function add(a, b) {return a + b;}
I was surprised when I learned that the TypeScript compiler actually understands JSDoc comments. This fact allows you to type your entire codebase without creating a single .ts
file.
Think of this post as your crash course in using JSDoc as an alternative syntax for TypeScript. We’ll cover all the important TypeScript-related features JSDoc has to offer—and their limitations.
JSDoc
JSDoc is expressed through block comments in the form /** */
, which may contain block tags such as @param
and @type
. Normal //
and /* */
comments don’t work:
// @type {number}let a; // Doesn't work/* @type {number} */let b; // Doesn't work/*** @type {number} */let c; // Doesn't work/** @type {number} */let d; // Works!
The majority of your JSDoc block tags will be used for typing variables, arguments, and return types. The block tags for those are @type
, @param
, and @returns
.
/*** @param {string} message* @returns {number}*/function len(message) {return message.length;}/** @type {{ name: string, age: number }} */const user = {name: "Alex",age: 26,};
Type casting
Type casting in TypeScript can be done using expression as T
or <T>expression
:
function example(arg: unknown) {const num = arg as number;const str = <string>arg;}
Type casting in JSDoc is done by wrapping the expression in parentheses and adding a preceding @type
comment:
/** @param {number} num */const square = (num) => num * num;/** @param {unknown} arg */function example(arg) {const num = /** @type {number} */ (arg);return square(num); // OK!}
The parentheses are required. If they are missing the cast will not work:
/** @param {number} num */const square = (num) => num * num;/** @param {unknown} arg */function example(arg) {const num = /** @type {number} */ arg;return square(num);// Argument of type 'unknown' is not assignable to parameter of type 'number'.}
Missing parentheses are a really easy mistake to make, which can easily lead to bugs when casting from any
. Be careful with casts in JSDoc!
Const assertions
TypeScript supports const assertions, which can be quite useful.
function resize(options: { size: 1 | 2 }) {// ...}const a = { size: 1 as const };const b = { size: 2 };resize(a); // OKresize(b);// Types of property 'size' are incompatible.// Type 'number' is not assignable to type '1 | 2'.
You can also use const assertions in JSDoc, they’re just a type cast:
/*** @param {{ size: 1 | 2 }} options*/function resize(options) {// ...}const a = { size: /** @type {const} */ (1) };const b = { size: 2 };resize(a); // OKresize(b);// Types of property 'size' are incompatible.// Type 'number' is not assignable to type '1 | 2'.
Declaring types
In TypeScript, you can declare types using the type
or interface
keywords:
type Value = string | number;interface Store {value: Value;set(value: Value): void;}
In JSDoc, types are declared using the @typedef
keyword:
/*** @typedef {string | number} Value*//*** @typedef {{ value: Value, set(value: Value): void }} Store*/
Having declared a type with @typedef
, you can reference it like any other TypeScript type:
/** @type {Value} */const value = 5;/** @type {Store} */const store = {value,set(value) {this.value = value;},};
An alternative way to declare the properties of an object type is using @property
:
/*** @typedef {object} Store* @property {Value} value* @property {(value: Value) => void} set*/
Nested properties can be specified using .
as a separator:
/*** @typedef {object} User* @property {object} name* @property {string} name.first* @property {string} name.last*//** @type {User} */const user = {name: {first: "Jane",last: "Doe"},};
Exporting types
There’s no syntax for exporting types in JSDoc. Instead, types defined using @typedef
are exported by default. This auto-exporting applies to all types declared at the top level of a module.
As someone who cares a lot about the interfaces of modules, I strongly dislike this feature.
You can avoid the auto-exporting by declaring types in the scope that they’re needed in:
// 'Foo' is declared at the top level and can thus be imported by// other modules./** @typedef {string} Foo */{// 'Bar' is declared in a block scope: it cannot be imported by// other modules./** @typedef {string} Bar */}function example() {// 'Baz' is declared in a function: it cannot be imported by// other modules./** @typedef {string} Baz */}
One thing worth mentioning is that types declared in JavaScript modules using JSDoc can be imported from TypeScript modules.
Importing types
In TypeScript, you can reference types from other modules via import
statements or import("./path").Type
:
import { Foo } from "./module";let foo: Foo;let bar: import("./module").Bar;
Note: In TypeScript modules you can declare that the import is for a type via import type { Foo }
or import { type Foo }
JSDoc allows you to use import("./path")
, like in TypeScript:
/** @type {import("./module").Foo} */let foo;
That can get quite verbose for long module paths and type names. You can instead import types via the @import
tag introduced in TypeScript 5.5:
/** @import { Foo } from "./module" *//** @type {Foo} */let foo;
If you’re using an older version of TypeScript, you can instead “fake” normal imports using @typedef
:
/** @typedef {import("./module").Foo} Foo *//** @type {Foo} */let foo;
But keep the auto-exporting footgun in mind! As mentioned in Exporting types, types defined via @typedef
are auto-exported, which means that the type is re-exported.
// This.../** @typedef {import("./module").Foo} Foo */// ...is equivalent to thisimport { Foo } from "./module";export { Foo } from "./module";
Non-null assertions
We’ve arrived at my largest gripe with JSDoc: it doesn’t support non-null assertions.
Take the Map<K, V>
data structure as an example. The return type of Map<K, V>.get
is V | undefined
, which can be frustrating when you know for certain that a value is non-null.
const map = new Map<string, number>([["a", 1],["b", 2],]);const a: number = map.get("a");// Type 'number | undefined' is not assignable to type 'number'.
In TypeScript, you can use !
after an expression to assert that it is non-nullable.
const a: number = map.get("a")!; // OK!
There is no equivalent @nonnull
tag or syntax in JSDoc.
One possible workaround is to use a type cast like so:
/** @type {number} */const a = /** @type {number} */ (map.get("a"));
The problem with type casts is that they can become incorrect as the code evolves. Imagine that map
is updated to store string | number
instead of just number
:
/** @type {Map<string, number | string>} */const map = new Map([ ... ]);/** @type {number} */const a = /** @type {number} */ (map.get("a"));
Type casting string | number | undefined
to number
is valid, so we get no type error. The type cast masks the type error, which would have not happened using non-null assertions.
const map = new Map<string, number | string>([ ... ]);const a: number = map.get("a")!;// Type 'string | number' is not assignable to type 'number'.
There is one safe way to express non-nullability in JSDoc, which is using the NonNullable
type in conjunction with typeof
. Any expression expr
can be declared non-nullable by casting it to NonNullable<typeof expr>
, though this can be quite verbose.
/** @type {Map<string, number>} */const map = new Map([ ... ]);/** @type {number} */const a = /** @type {NonNullable<ReturnType<typeof map.get>>} */ (map.get("a"));
We can make this more readable like so:
const aNullable = map.get("a");/** @type {number} */const a = /** @type {NonNullable<typeof aNullable>} */ (aNullable);
But this is still terribly noisy! This would be much cleaner if @nonnull
were supported:
/** @type {number} */const a = /** @nonnull */ (map.get("a"));
This issue is being tracked in #23405 in microsoft/TypeScript. Let us pray that @nonnull
will be added at some point.
Optional parameters
Parameters can be marked as optional in TypeScript using ?
:
function foo(a: number, b?: boolean) {// ...}foo(1); // OK
In JSDoc, you can mark parameters as optional by wrapping their name in []
:
/*** @param {number} a* @param {boolean} [b]*/function foo(a, b) {// ...}foo(1); // OK
A parameter can also be implicitly marked as optional by providing a default argument, just like in TypeScript:
/*** @param {number} a* @param {boolean} b*/function foo(a, b = false) {// ...}foo(1); // OK
There is an alternative syntax for marking parameters as optional where =
is placed after the type:
/*** @param {number} a* @param {boolean=} b*/function foo(a, b) {// ...}foo(1); // OK
I find this syntax a bit weird, but hey, it’s supported.
Generic type parameters
Declaring a generic type parameter is done using @template
:
/*** @template T* @param {T} value* @returns {{ value: T }}*/function box(value) {return { value };}// Equivalent TypeScriptfunction box<T>(value: T): { value: T } {return { value };}
Expressing an extends
constraint is done like so:
/*** @template {string | number} T* @param {T} value* @returns {{ value: T }}*/function box(value) {return { value };}// Equivalent TypeScriptfunction box<T extends string | number>(value: T): { value: T } {return { value };}
The @template
block tag can also be used for type definitions, classes, methods, and more.
// Declaring a generic type/*** @template T* @typedef {{ value: T }} Box*/// Referencing a generic type/** @type {Box<number>} */const box = { value: 5 };// Creating a generic class/*** @template T*/class Box {/*** @param {T} value*/constructor(value) {this.value = value;}}
Class properties
In TypeScript, you can declare class properties using the public
/private
keywords for constructor arguments or by explicitly declaring the properties.
class Vector2 {// Use 'public' keyword to declare 'x', 'y' and// automatically assign them.constructor(public x: number, public y: number) {}}class Vector2 {// Explicitly declare propertiespublic x: number;public y: number;constructor(x: number, y: number) {// Manually assign to propertiesthis.x = x;this.y = y;}}
Since JavaScript does not support the public
/private
keywords, we need to take the latter approach and assign manually:
class Vector2 {/*** @param {number} x* @param {number} y*/constructor(x, y) {this.x = x;this.y = y;}}
Unlike TypeScript, we don’t need to explicitly declare x
and y
as properties in JavaScript modules. They are implicitly declared by assigning to them in the constructor.
However, I would argue that it’s good practice to explicitly declare the types of class properties to avoid possible implicit any
s.
class Vector2 {/** @type {number} */x;/** @type {number} */y;/*** @param {number} x* @param {number} y*/constructor(x, y) {this.x = x;this.y = y;}}
Class implements
In TypeScript you can declare that a class implements
a certain interface:
interface IEventEmitter {emit(type: string): void;subscribe(type: string, callback: () => void): void;}class EventEmitter implements IEventEmitter {// ...}
It won’t come as a surprise to hear that JSDoc has @implements
for this purpose:
/*** @typedef {object} IEventEmitter* @property {(type: string) => void} emit* @property {(type: string, callback: () => void) => void} subscribe*//*** @implements {IEventEmitter}*/class EventEmitter {// ...}
Public and private properties and methods
Properties and methods can be declared as public
and private
via @public
and @private
:
class Example {/*** @public* @type {number}*/foo;/*** @private* @type {string}*/bar;}// Equivalent TypeScriptclass Example {public foo: number;private bar: string;}
Typing this
TypeScript enables you to type the this
argument for a function or method:
interface Context {scale: number;}function foo(this: Context, value: number) {// ...}
JSDoc contains an @this
keyword for this purpose:
/*** @typedef {{ scale: number }} Context*//*** @this {Context}* @param {number} value*/function foo(value) {// ...}
@ts-*
comments
All of your normal @ts-*
comments, such as @ts-ignore
, work as expected:
/** @type {string} */// @ts-ignorelet x = 5;
Practical matters
We’ve now gone through all the major (in my opinion) TypeScript-related features in JSDoc. They should cover the vast majority of TypeScript features you’ll ever need in JSDoc.
We’ll now cover some practical things to know if you intend to use JSDoc with TypeScript.
Enable checkJs
As we’ve seen, type annotations in JSDoc comments are used as type information in .js
files.
/*** @param {string} message*/function log(message) {// ...}log(12);// Argument of type 'number' is not assignable to parameter of type 'string'.
However, checkJs
needs to be enabled in your tsconfig.json
for type errors to be emitted. If you don’t enable checkJs
, your JSDoc comments will only be used for IDE annotations—not type checking. Be sure to enable it!
TypeScript interop
If you type a function using JSDoc in a .js
module, you can import that function in a .ts
module without any issues. This also works the other way: you can import things from .ts
modules and use them in .js
modules.
Generally, interop between .js
modules using JSDoc and .ts
modules “just works”.
JSDoc does not work in TypeScript modules
You can’t use JSDoc for type annotations in .ts
modules. This can make migrating from JSDoc to TypeScript a bit frustrating, especially for larger modules.
Conclusion
I worked in a JSDoc codebase for a significant amount of time, and have gone through the process of migrating a lot of that codebase to TypeScript. JSDoc definitely has flaws, such as its clunky and verbose syntax, but it’s still a perfectly viable way to go about typing your codebase.
If you’re not able to use TypeScript for some reason, then consider giving JSDoc a shot. It’s better than no types.
To be notified of new posts, subscribe to my mailing list.