Joshua's Docs - JSDoc Cheatsheet and Type Safety Tricks

Warning: A lot of this page assumes you are using VSCode as your IDE, which is important to note because not all IDE's handle JSDoc the same way. I also focus on how JSDoc can provide type-safe JS through TypeScript, as well as through VS Code's wrapper around TypeScript.

Table of Contents

Resources

What & Link Type
Official JSDoc Docs Official Docs
Official TypeScript Docs, covering JSDoc usage, supported usage Official Docs
devdocs.io/jsdoc Searchable Docs
Devhints Cheatsheet Cheatsheet
theyosh.nl Cheatsheet Printable Cheatsheet
Repo: Types in JS GitHub repo for discussing or getting help with JSDoc

Advanced Usage

Type Guards

This is pretty crazy, but you can actually implement a type-guard completely in JSDoc! After a bunch of digging, I found the right syntax thanks to this Github issue comment.

Syntax:

// Where YOUR_TYPE is understood type (either naturally inferred, or through JSDoc)

/**
 * @param {any} value
 * @return {value is YOUR_TYPE}
 */
function isYourType(value) {
	let isType;
	/**
	 * Do some kind of logical testing here
	 * - Always return a boolean
	 */
	return isType;
}
Full Example:
// @ts-check
/**
 * @typedef {{model: string}} Robot
 */

/**
 * @typedef {{name: string}} Person
 */

/**
 * @param {object} obj
 * @return {obj is Person}
 */
function isPerson(obj) {
	return 'name' in obj
}

/**
 * Say hello!
 * @param {Robot | Person} personOrRobot
 */
function greet(personOrRobot) {
	if (isPerson(personOrRobot)) {
		// Intellisense suggests `.name`, but not `.model`
		console.log(`Hello ${personOrRobot.name}!`);
		// Below will throw type error
		console.log(personOrRobot.model);
	} else {
		// Intellisense suggests `.model`, but not `.name`
		console.log(`GREETINGS ${personOrRobot.model}.`);
	}
}

Annotating destructured parameters

If you want to annotate the type of variables that are assigned via destructuring, it can be a little complicated with JSDoc.

For destructuring directly, it looks like this:

/**
 * @typedef {object} DestructuredUser
 * @property {string} userName
 * @property {number} age
 */
/** @type {DestructuredUser} */
const {userName, age} = getUser();

For annotating on a function argument:

/**
 * @param {object} obj
 * @param {string} obj.userName
 * @param {number} obj.age 
 */
function logUser({userName, age}){
	console.log(`User ${userName} is ${age} years old.`);
}

... Or with a separate typedef

/**
 * @typedef {object} DestructuredUser
 * @property {string} userName
 * @property {number} age
 */
/** @param {DestructuredUser} param */
function logUser({userName, age}){
	console.log(`User ${userName} is ${age} years old.`);
}

You can even annotate on arrow functions:

/**
 * @param {object} obj
 * @param {number} obj.total
 * @param {string} obj.vendorName
 */
const printReceipt = ({total, vendorName}) => {
	// total and vendorName will have correct types! :)
}

Annotating function signatures / function as a type

This looks pretty similar to TS - return type should come after arguments with colon:

/**
 * @typedef {function(string): string} StringProcessor
 */

// It is far easier to write as a multi-line typedef with arg names
/**
 * @typedef {function} StringProcessor
 * @param {string} inputStr - String to transform
 * @returns {string} Transformed string

Here are some practical examples:

/**
 * @typedef {function(string): string} StringProcessor
 */

// Annotating single variable
/**
 * @type {StringProcessor}
 */
const upperCaser = (input) => {
	return input.toUpperCase();
}

// Within another typedef
/**
 * @typedef {Object<string, StringProcessor>} StringProcessorsCollection
 */
/**
 * @type {StringProcessorsCollection}
 */
const myStringMethods = {
	lowerCase: function(input) {
		return input.toLowerCase();
	}
}

Classes and Constructors

In general, documenting ES2015 (ES6) classes and their associated parts (constructor, methods, etc.) is well-supported in JSDoc. You can find some great examples of how to do this in:

However, there are some additional notes I would like to add:

  • You can document a class outside of its implementation, however, this is incredibly complex with pure JSDoc, and I would strongly recommend just using a .ts file for this type of use-case.
    • This is hard to do in general, and there are not many good reasons to be doing it anyways
    • This is an example of defining a constructor on an interface
  • Trying to document a class that is structured as an IIFE with JSDoc is... very difficult. In fact, I would recommend never even attempting to do so; I'm fairly certain there might be some open bugs that make this almost impossible
    • This is especially true if you are also trying to generate declaration files files from your JSDoc; I kept running into a "Declaration emit for this file requires using private name" error
    • I think most of the ways to get this to work require a lot of explicitly creating types outside of the IIFE, then importing those types in and merging them; lots of extra work

Casting and Type Coercion in JSDoc

Probably the most accurate term is Type Assertion

At least in @ts-check land, you probably won't have to deal with casting in JSDoc much. For example, usually annotating the type during assignment is enough to cast an any to a specific type.

However, for casting unknown or situations with inferred "no overlap", there are some other escape hatches.

Simple Casting

One way is to wrap with parenthesis during assignment:

/** @type {unknown} */
let huh;

/**
 * Works!
 */
/** @type {number} */
const success = (huh);

/**
 * FAILS
 */
/** @type {number} */
const failed = huh;

TIP: Many formatters remove "unnecessary" parenthesis wrapping (such as Prettier), but they will preserve it if you inline the JSDoc annotation with the code. Like so:

const success = /** @type {number} */ (huh);

Advanced Casting

If the above didn't work, you are probably in a "neither type sufficiently overlaps" situation. I actually couldn't find this documented anywhere, but this worked for me, although it is not pleasant to look at!:

/**
 * @typedef {object} Person
 * @property {string} name
 * @property {number} age
 */

/** @type {number} */
let something;

/**
 * Works!
 */
const success = /** @type {Person} */ (/** @type {unknown} */ (something))

/**
 * FAILS
 */
/** @type {Person} */
const failed = (something);

WARNING: Linters might automatically remove the parenthesis around the inner-most cast, which breaks this. You should be able to disable the linter on that specific line by following your specific linter instructions.

For prettier: // prettier-ignore disables for the next line.

WARNING: With the above solution, TS does not like trying to cast to the same variable (reassignment). Just create a new variable and copy into it while casting if you are trying to change the type of an existing one.

Non-Null Assertion in JSDoc

If you are using strict null checks, but also writing code where you have a lot of knowledge that TSC does not, it is not uncommon to often need to tell TypeScript that a value is definitely not null. This happens so often that they actually introduced a special operator for it, the Non-Null Assertion Operator (also sometimes called a post-fix expression operator assertion):

const myElement = document.querySelector('.my-class');
myElement!.setAttribute('data-greeting', 'hello!');
//       ^
//       |- Notice the `!`. Without this non-null assertion,
//          in strict mode this would throw, since myElement
//          could be type `Element | null`.

This syntax allows for much more concise code, with the alternative being some sort of inline type assertion, casting, or generic type guard. However, how do we use non-null assertion in JSDoc?

The short answer is that the concise post-fix operator (the exclamation mark) is not (yet) available to use in JSDoc.

However, there are workarounds that let you write less code while still avoiding disabling strict null checks (which, yes, is also a solution).

  • You could use a manual type assertion, as outlined above

    • Example:
    // Change this...
    const myElement = document.querySelector('.my-class');
    // To this:
    const myElement = /** @type {Element} */ (document.querySelector('.my-class'));
  • You could use a utility function which accepts a generic type slot, and which acts like a non-null assertion and can be easily called across your codebase

    • Example:
    /**
     * Utility function since can't use post-fix assert in JSDoc
     *  - Takes any union type and excludes `null`
     * @template T
     * @param {T} thing
     * @returns {Exclude<T, null>}
     */
    export function assertNonNull(thing) {
    	return /** @type {Exclude<T, null>} */ (thing);
    }
    
    // myElement type now becomes `Element` instead of `Element | null`
    const myElement = assertNonNull(document.querySelector('.my-class'));
    myElement.setAttribute('data-greeting', 'hello!');
    
    // Can be used for pretty much anything
    // This would normally be `string | null`, but now asserted as `string`
    const storedVal = assertNonNull(localStorage.getItem('alwaysHasValue'));
  • You could cast the type to any, or disable strictNullChecks (neither is recommended)

âš  Normally, asserting that a runtime value is going to be a specific type, when the API says it really could be one of several, would be incredibly unsafe. However, when working the DOM, you as the developer often have extra information that the TypeScript compiler does not - e.g., which class names you are using, elements that are injected elsewhere etc. Still, non-null assertion is something you should treat with care; unlike things like the optional chaining operator, this assertion does not change the runtime code and if the value actually is null, you would cause a runtime exception.

Generics in JSDoc

If you are a TypeScript user, you might already be wondering about generics. How can you use them with JSDoc?

Luckily, support for generics in JSDOC (with type-checking) landed in TypeScript a while ago.

For accepting generics, the syntax is actually easier than one might expect - the most important piece is to declare a generic with @template T. You can then reuse that generic type T, just like you can in TypeScript.

Here is a fully-functional example:

/**
 * Takes any object with a name prop and removes it
 * @template T
 * @param {T & {name?: string}} inputObj
 * @returns {Omit<T, 'name'>}
 */
const deleteName = (inputObj) => {
	/** @type {typeof inputObj} */
	const copy = JSON.parse(JSON.stringify(inputObj));
	delete copy.name;
	return copy;
}

// IDE will automatically infer type of T, and also infer the
// type of nameLess as {age: number, name: string} without `name`
const nameLess = deleteName({
	age: 50,
	name: 'Gregory'
});

âš  Of all the parts of JSDoc, this is one that is especially different based on IDE; not all IDEs support it, and some support it differently.

Important links:

Extending Interfaces

In native TypeScript, you can extend interfaces with the extends keyword:

TypeScript:

interface Media {
	title: string;
}
interface Book extends Media {
	author: string;
}

However, JSDoc doesn't really have a direct equivalent here, as @extends is for use with classes, not pure interfaces. At the moment (see issue #20077), the only real solution is to use intersection (&) or union (|) to create a combined interface, which works for many of the same use-cases as `extends.

For example, to replace the above TypeScript:

/**
 * @typedef {{title: string}} Media
 */

/**
 * @typedef {Media & {author: string}} Book
 */

If you want to extend another type within a typedef, while adding new props without a separate declaration, you can do something like this:

/**
 * @typedef {{title: string}} Media
 */

/**
 * @typedef {Media & {author: string}} Book
 */

Tuple Types

TypeScript now supports Tuple types expressed through JSDoc, in a variety of different ways.

Standard Tuple Type:

/** @typedef {[string, number]} NameAgeTuple */

/** @type {NameAgeTuple} */
const Robert = ['Robert Smith', 45];

Literal Tuple Type (helpful for exhaustive array checks, etc.)

/** @typedef {['Espresso', 'Drip']} CoffeeLiteralTuple */
// Above could also be written as `{readonly ['Espresso', 'Drip']}`

/** @type {CoffeeLiteralTuple & string[]} */
const coffeeTypes = ['Espresso', 'Drip'];
coffeeTypes.push('Cold Brew');

Literal Tuple Types are especially helpful, because they can also be reused as unions:

/** @typedef {['Dog', 'Cat', 'Bird']} PetsAllowedInLeaseTuple */

/** @type {PetsAllowedInLeaseTuple} */
const AllowedPets = ['Dog', 'Cat', 'Bird'];

/**
 * @param {PetsAllowedInLeaseTuple[number]} petType - Union pet type, extracted from tuple
 * @param {string} name
 */
const addPetToLease = (petType, name) => {
	//...
}

addPetToLease('Bird', 'Tweety');

Literal Types and Constants

String literal and constant based types are a powerful part of TypeScript, and they can be expressed in JSDoc too.

String Literal Types

// Basic string literal
/** @typedef {'v1.0'} VersionString */

// String literal union
/** @typedef {'cat' | 'dog'} Pet */

// (string) template literal type
// Expands to `cat-collar`, `cat-food_bowl`, `dog-collar`, `dog-food_bowl`
/** @typedef {`${Pet}-${'collar' | 'food_bowl'}`} Accessory */

Const Assertions

Const assertions are one of my favorite TypeScript "hacks".

e.g. using as const

If you are using TypeScript version 4.5 or higher, there is now a shorthand way to use const assertions in JSDoc as well (PR here):

const AppInfo = /** @type {const} */ ({
	appName: 'Widget Factory',
	author: 'Joshua',
});

Prior to v4.5, it takes way more code to express the same constants:

/** @type {{ appName: 'Widget Factory', author: 'Joshua'}} */
const AppInfo = {
	appName: 'Widget Factory',
	author: 'Joshua',
};

Importing external / exported types directly in JSDoc

This is kind of a crazy feature but you can actually import types from specific modules by using import directly within a JSDoc block! For example, if we wanted to explicitly type a variable as NodeJS's "fs" -> "Stats" type, we could use:

/**
 * @type {import('fs').Stats}
 */
let fsStats;

// Or, via typedef
/** @typedef {import('unist').Node} AstNode */

You can read a bit more about this feature (implemented in 2018) in various spots:

Importing Non-TS Code and/or CommonJS Module.Exports

If you try to use the above trick to import something that is not a type, and/or specifically part of a CommonJS module.export = {} pattern, you might get a "namespace has no exported member" error. Example:

/**
 * @typedef {typeof import('../my-js-file').myExportedFunc} MyFunc
 */

// ERROR: Namespace '"___/my-js-file".export=' has no exported member 'myExportedFunc'.ts(2694)

This appears to be a bug, and there is an open issue to track it (#29485), however, luckily in the meantime there is a workaround! - using bracket notation instead of dot notation.

Thanks to this comment, I have tried and verified that the following works:

// Use `import(FILE_PATH)['EXPORTED_THING']` instead of `import(FILE_PATH).EXPORTED_THING
/**
 * @typedef {typeof import('../my-js-file')['myExportedFunc']} MyFunc
 */

Inline Code Examples

This is a feature of JSDoc that I think is pretty neat, since I tend to believe that documentation is more likely to be kept up-to-date the closer it lives to the source code, so having inline examples of how to use / call a function right next to its implementation is pretty cool.

The generic JSDoc tag to add an inline example of how to use the thing you are documenting is the @example tag. However, VSCode implements an extended version, which supports multi-line examples + language specifiers (Markdown style).

The easiest way (IMHO) to format these is with fenced code blocks, using backticks, just how you would in Markdown. For example:

/**
 * @typedef {'pinball' | 'skee-ball' | 'ddr' | 'whack-a-mole'} GameName
 */

/**
 * Get an adjusted point total, based on game weight and house rules
 *  - Make sure to pass un-adjusted scores
 * @example
 * ```js
 * const adjusted = getAdjustedTotal({
 *    ddr: 2500,
 *    "whack-a-mole": 10
 * }).adjustedTotal;
 * ```
 * @param {Partial<Record<GameName, number>>} gameHistory
 * @returns {{total: number, adjustedTotal: number, avgWeight: number}} totals
 */
function getAdjustedTotal(gameHistory) {
    // implementation details omitted
}

VSCode - JavaScript Type Safety with JSDoc

Intro to Type Safety with JSDoc

VSCode has an awesome baked-in feature, which is that it can provide JS type-safety tooling, powered by JSDoc comments. This is built-in to VSCode and requires no extra tooling, transpiling, or even config files.

It is important to note that under the hood, this is still using TypeScript compiler; TypeScript is providing the type-checking, but VS Code is providing the UX and automation on top of it.

How to Trigger the JS Type Checker

Triggering the built in JS Type Checker options:

  • Add //@ts-check to top of JS file
  • Add setting to preferences:
    • "javascript.implicitProjectConfig.checkJs": true
      • Can be added to either global or workspace settings.json
  • Add a tsconfig.json or jsconfig.json file in the project to control the settings (and add checkJs: true)
    • If using tsconfig.json, also add allowJs: true

Although these built in options internally ("salsa" engine?) actually use TypeScript features to do the checking/linting (you can read more about that here), they use JSDoc to augment the interpretation/inferred types of JS.

Programmatically Scanning JS for Type Issues

You might notice that VSCode tends to only check and report type problems on JS files that are currently open, instead of every JS file in the workspace. To get around this, you have a few options:

  • Check files with the TypeScript compiler, directly
    • You can actually just run your JS through TSC, even without any TS or a tsconfig. Use noEmit to make sure no files are output.
      • Example: tsc --noEmit --project jsconfig.json
    • This is also a way to use JSDoc for type-safety, even without VSCode! You can basically treat your project as a TypeScript project, even though it is written in JavaScript.
    • NOTE: Even if you run this command from within VSCode, it won't make the problems caught automatically show up in the VSCode problems panel.
  • Use VSCode tasks and problemMatcher option
    • If you want all problems to be reported to and displayed in VSCode, you are (currently) forced to create a custom task. This will let VSCode run the TSC command directly and use the problemMatcher option to help it understand how to interpret the CLI response.
    • See this S/O and this open issue thread for VSCode
  • Try out the experimental enableProjectDiagnostics setting (see this S/O)

Simple Example:

WARNING: JSDoc TS annotations MUST be in a multi-line comment block (/** ___ */) comment to be be parsed. They are ignored in single line (//) comments!!!

/**
 * @type {google.maps.StreetViewPanorama}
 */
let pano = this.mapObjs.svPano;

/**
 * @type Array<{localPath:string, fullPath: string}>
 */
let filePaths = [];

/**
 * @typedef {"Hello" | "World"} MyStringOptions
 */

// You can even re-use types!
/**
 * @typedef {object} Person
 * @property {string} name - Person's preferred name
 * @property {number} age - Age in years
 * @property {boolean} likesCilantro - Whether they like cilantro
 * @property {string} [nickname] - Optional: Nickname to use
 */


/**
 * @type Person
 */
let phil = {
	name: 'Phil Mill',
	age: 25,
	likesCilantro: true
}

/**
 * @type Person
 */
let bob = {
	name: 'Robert Smith',
	age: 30,
	likesCilantro: true,
	nickname: 'Bob'
}

/**
 * 
 * @param {Person} personObj 
 */
function analyzePerson(personObj){
	// Intellisense will fully know the type of personObj!
	console.log(personObj.name);
}

VSCode - Advanced JS Type-Safety with JSDoc

Package Types from node_modules

And you can even install module type defs, like those from @types. This works the same as in TypeScript, so see my notes about "including external type definition files" for details. For most JS projects, since you are likely missing a tsconfig.json file, the easiest option might be a one-off triple-slash directive usage.

For pulling in types from a module, one at a time, you can use the import syntax within JSDoc. I already have a section on this elsewhere on this page.

This is an extremely helpful section in the VSCode docs: "Typings and Automatic Type Acquisition"

JSDoc Globals in VSCode

Unfortunately, for globals, it looks like JSDoc or inline comments are not supported (issue #15626, doc change) the only way to tell VSCode about those is through a TypeScript "ambient declaration file" (e.g. *.d.ts file). For example, you can create a globals.d.ts file, declare your globals / interfaces / etc., and VSCode's type checker will pick up on it, despite your project being JS code.

You can also change how you declare them; explicitly creating via global.{yourVarName} (or globalThis) will suppress cannot find name {yourVarName} errors, and the IDE should even preserve the type if the variable is read in a different file.

Scripts vs Modules, Block-Scoped Variables

The default behavior of TS is that a file is a module if it imports or exports anything, otherwise it is a script. And, if it is a script, due to the lack of closure, top level variables become global. Sometimes this is accurate, for example in this setup:

<!-- Files:
 - index.html
 - file_A.js
 - file_B.js
 -->
<!-- index.html -->
<script src="./file_A.js"></script>
<script src="./file_B.js"></script>

If both files A and B are scripts, not modules, and both have a top level variable declared as const myStr = 'hello';, you will get an error:

Cannot redeclare block-scoped variable 'myStr'.ts(2451)

Again, in the above scenario, this makes sense; VSCode and TS are correct. If we wanted to force these files to be isolated from each other, while still running in index.html, we could:

  • Change the script tags to use <script type="module"> for ES6 module encapsulation (caniuse)

However, this actually don't tell VSCode to interpret the files any differently, and the error wont go away. Furthermore, VSCode will continue to throw the error even if the files are scripts, but won't conflict due to directory separation. For example:

- jsconfig.json
- dir_A/
	- index.html
	- file_A.js
- dir_B/
	- index.html
	- file_B.js

Even if file_A is only used in dir_A/index.html, and file_B is only used in dir_B/index.html, TSC will still complain about any top-level variables that exist in both files and it thinks will conflict (they won't). How do we explicitly tell TSC about this?

  • If we want to force these files to act like modules, we can add an empty export declaration:
    • Syntax
      • ESM: export {}
      • CJS: module.exports = {}
    • In HTML, you also need to update script tags to use <script type="module"> when importing
    • This also prevents variables from becoming globals, which might be something that you actually don't want to lose.
  • For our exact scenario, with pure JS in a web environment, our options are:
    • Move config files down
      • Add: /dir_A/jsconfig.json
      • Add: /dir_B/jsconfig.json
      • Delete: /jsconfig.json
      • Reload your workspace and/or TS engine
    • Give top-level variables closure (e.g. by wrapping in an IIFE)
      // Change this...
      const myStr = 'hello'
      console.log(myStr);
      
      // ...to this:
      (() => {
      	const myStr = 'hello';
      	console.log(myStr);
      })();
    • Change variable type to something that is not block-scoped
      • Change const or let to var

Also, although you might think it would work, turning off / deleting all config files and then individually adding the // @ts-check directive to each file does not fix it

I've asked for a better solution to this problem on StackOverflow. If you know of a better approach, you should respond there, so you can claim some credit!

How to ignore errors in multi-line blocks (e.g JSX)

You basically can't, at least not at the moment - see:

VSCode Type-Safety JSDoc - Issues

If you run into issues with this, here are some things to check or try:

  • "cannot find name ___ ts(2304)"
    • VSCode seems to have an issue with ambient declaration files (*.d.ts) being picked up without a config file. Any of the following should work:
      • Add a tsconfig.json file (or jsconfig.json, as a fast alternative)
        • Bare-bones fix: add jsconfig.json with contents of {} or something like {"exclude": ["node_modules"]}
      • Use a triple slash directive at the top of the file, for example: /// <reference path="../types.d.ts"/> or /// <reference types="googlemaps"/>
      • Keep the ambient type file open while editing (right click tab, "Keep Open") - quick hack
    • Can you add an import that brings in the namespace'd type?
    • Is the type declared under a namespace or module that doesn't match where you are trying to use the type?
    • If you have used import or export in a TS file that you are trying to use as an ambient type declaration file, make sure types are wrapped with declare global {}.
    • If using a tsconfig.json file, use tsc --listFiles --p {pathToTsConfigJson} to help see what files are getting included (kudos)
  • Type assigned to CommonJS (require()) variable is typeof import(), and you aren't getting the correct type that corresponds with the exported variable
    • Instead of const myImport = require('my-lib'); try const myImport = require('my-lib').default;
    • The above is actually a TS / CommonJS interop problem, not a JSDoc problem
  • Using a tsconfig.json for checking JS doesn't seem to be working / rules are not enforced
    • Do you have both allowJs and checkJs on?
    • Check for outdated / mismatched TypeScript versions. You can force VSCode to use a newer, or specific version of TS, by following these steps
      • You can always install typescript as a devDependency, and then use settings.json to tell VSCode to use it as the active version
    • Do you accidentally have a TS file with the same filename (omitting extension) in the same folder? E.g. src/helpers.ts and src/helpers.js?
      • If this is the case, TSC will ignore the .js file, even if you explicitly add the file to include and have noEmit: true (you would think noEmit would do the trick, but no).
    • As a last resort, if you really don't need full TS support, try just using a jsconfig.json config instead (which supports most of the same options)

Important reference material

Further reading

Here are some more guides on trying to get the benefits of TypeScript in JS (type-safety):


Sharing JSDoc Types Internally (Importing, Exporting, and Ambient Declarations)

For exporting / emitting types from JSDoc (e.g., if you are a library author), see my separate section below on generating files from JSDoc.

One of the complaints about JSDoc is that it can be verbose. With that in mind, being able to share types internally in a JSDoc-driven project is a common concern.

There are several ways that types can be shared and reused within a JSDoc project, between files, but it can depend on what module system and constraints you are working with. Here are some options:

Passing Typedefs Around

Typedef blocks are the main way to create custom types in pure JSDoc code, and they can be reused across files.

/** @typedef {{username: string, isAdmin: boolean, age?: number}} User */

If you are not using modules, the User type created above can be referenced in other files without an explicit import statement. However, if you are using modules, you need to add one, like so:

// File alpha.js
/** @typedef {{username: string, isAdmin: boolean, age?: number}} User */

// This is an empty export to illustrate that `alpha.js` is a module.
export {}

// File beta.js
/** @type {import('./alpha.js').User} */
const joe = {
	username: 'Joseph',
	isAdmin: false,
	age: 85
}

In the above example, a @typedef {} JSDoc block in a JS module file acts like how export type MY_TYPE acts in a TS file.

Passing Inferred Types Around

TypeScript has strong type inference and can easily follow types around as they get attached to variables that flow through your code. If you are importing actual run-time variables that have annotated types, you might not need to actually add types when importing.

Show / Hide Example
// File alpha.js
/** @typedef {{username: string, isAdmin: boolean, age?: number}} User */

/** @type {User} */
const joe = {
	username: 'joe123',
	isAdmin: false,
	age: 85
}
export {
	joe
}

// File beta.js
// joe will have type of `User` attached, automatically
import { joe } from './alpha.js';

// We can even re-use the `User` type attached to joe, without explicit import
/** @type {Array<typeof joe>} */
const adminUsers = [
	{
		username: 'Alfonso',
		isAdmin: true
	}
]

Passing Types around via TypeScript Files

Many JSDoc users are using it because they are trying to get away from transpilers and associated tooling, so I put this last, but this is is still worth mentioning because using TS files in a JSDoc project does not necessarily mean you need to involve a transpilation step. If all you are using from TS files is types, you don't have to transpile your code!

Using typescript files to share types between JS files in a JSDoc project actually works similarly to the two options outlined above (purposefully).

Using ambient declaration files (*.d.ts, global type files) is analogous to writing @typedef comments in a global JS file that is not a module (no exports). And TS types can also be explicitly imported in a JS file, via the same @type {import('FILE_PATH').IMPORT} statements.

My personal preference when using TS to write types in a large JSDoc project is to use ambient declaration files; that way I don't have to write a lot of lengthy import statements all over the place, and can consolidate my more advanced types in just one or two spots.


Generating Files From JSDoc

Some important links:

Emitting Type Definitions / Declarations from JSDoc Types

The main benefit of using JSDoc for type checking is really just the improvement of the IDE experience; unlike traditional TypeScript, you are not actually compiling your code.

However, if you are building a library, such as an NPM package, you might actually want to generate some output files; mainly a type declaration file.

This page from the TS Handbook covers how to setup a JSDoc project to output declaration files, but I'll also summarize below:

If you want to emit a types file based on JSDoc, the main thing you need to do is:

  • Make sure you are using a tsconfig.json file
    • If you are using a jsconfig.json file, you will probably want to switch, or create an additional special tsconfig.{name}.json file just for emitting (such as tsconfig.emit.json)
      • You can use jsconfig.json files with TSC, but there are some related issues you might run into
    • At a bare minimum the config needs to have:
      {
      	"compilerOptions": {
      		"allowJs": true,
      		"declaration": true,
      		"noEmit": false
      	}
      }
    • You probably also want:
      • "module": "commonjs"
      • "moduleResolution": "node"
      • "esModuleInterop": true
    • You can use "emitDeclarationOnly": true if you don't want to copy the JS files and/or modify them in any way for distribution
      • If you do want TSC to copy the JS files over, it can even handle some basic transpiling for you! Use the compilerOptions.target to specify the desired output ES version.
    • Additionally, use compilerOptions.outDir or compilerOptions.outFile to control the location and/or name of the declaration file(s)
      • If you want a single emitted file, use outFile (but this be warned this is incompatible with commonjs)
  • Add typescript as a dev dependency, so everyone can use tsc
  • Call tsc with the config, which will create the file
    • If you have a special named config, pass via the --project flag.
    • tsc --project tsconfig.emit.json
  • Add the (final) generated *.d.ts file (or directory that contains the generated files) to your package.json, under the types field

Be very careful using *.d.ts files in your source code, especially if you are relying on generated declaration files. Ambient declaration files (*.d.ts) are essentially used as "guides" to the compiler; they are not considered for output file generation and do not emit any JS.

You also need version >= 3.7 of TypeScript in order for this to work (Ref A, Ref B), Ref C

There are alternative generators other than the TypeScript compiler, such as tsd-jsdoc, but TSC is recommended if it can work for your situation.

Where to Output JSDoc Generated TS Declaration Files

When using TSC to produce *.d.ts files from JS source code, you have a few options on where to place those files. The common options seem to be:

  • Don't transpile JS (emitDeclarationOnly: true), and emit to /types(via outDir)
    • You can optionally use declarationDir instead of outDir to control where types are emitted
  • Transpile JS, and emit JS + TSD to /dist (via outDir)
  • Don't transpile JS, and don't emit to a separate folder; emit alongside JS files: this is basically the default config
    • Negative: this complicates organizing src code vs published code
      • Many people argue that generated files should not be in version control; if you follow this advice, you would need to carefully gitignore the specific generated *.d.ts files that are among your source files, but also make sure they are published to NPM

Emitting a Single Declaration File

In general, bundling all the emitted types into a single declaration file is not really necessary or possible with TS repos (see my disclaimer in my package authoring notes), but for generating from JSDoc, you can easily accomplish this with emitDeclarationOnly: true, and the outFile option.

Another approach - the tsd-jsdoc library is oriented towards this goal. Here is a guide on using it.

Caveats for Using JSDoc to Generate Declaration Files

The biggest caveat with using JSDoc to generate declaration files (especially for a NPM package entry point) is that (currently) it feels like an "all-or-nothing" approach. There is a limited set of controls to alter the behavior of the generated files (to be fair, JSDoc is not really the main purpose of TSC 😂), and once the files are generated, they aren't really meant to be hand-edited.

So, you usually see one of a couple options:

  • Authors hand-edit the TS declaration files that are included in distribution, and also check them into VC (example). TSC is not used for generation.
  • Authors use TSC for declaration generation, omit the files from VC (e.g. in /dist), and don't modify the generated output
  • Authors use TSC for declaration generation, but include the generated (vendored) declaration files in VC
    • This is usually combined with outFile to force a single declaration file, and having it created alongside the rest of the source files
    • Although this is checked into VC, not wise to hand-edit after generation, since changes will be wiped out by future generations
  • Authors only use TSC to generate the initial set of types, and then hand-edit them afterwards
    • I would not recommend this approach. I don't consider it a good idea to maintain duplicate type definitions (inline via JSDoc, and the declaration files). Plus, running TSC will overwrite your changes. This speaks to the "all or nothing" problem...
  • Authors merge generated declaration files with hand-coded types
    • As a build step, you can always copy over hand-coded declaration files that can supplement the generated files from TSC
    • This is somewhat common, especially for *.d.ts files, since those are not bundled with TSC's generated output

How To Export Types from Index Entry Point

If you are using TSC to generate declaration files from pure JavaScript with JSDoc, you might run into the funny question of how to export types (not implementations) from the entry-point (e.g. index.js) of your library / app.

Normally, in a TypeScript project, you can easily export types just the same as you can export functions, classes, etc. You might even do something like export * from './my-types', where you import and then re-export types that your consumers will need, from within the entry-point.

But, in JS files, types only exist in JSDoc comments; how do you export from JS-land?

Options:

=== Export types one-by-one, with @typedef blocks ===

/**
 * @typedef {import('./my-types').Dog} Dog
 */
/**
 * @typedef {import('./my-types').Cat} Cat
 */

Even if you never use the imported type within the JS file you declare it in, TSC will export them in the generated *.d.ts file!

=== Export types in bulk ===

Currently not supported (AFAIK, and backed up by this comment by TruckJS) 😢

Ideally, this would work the same way it does in TypeScript, where you could write something like:

// These are NOT supported, and are merely for illustrative purposes
/**
 * @typedef {import * from 'my-types'} MyTypes
 *  - Or maybe,
 * @typedef {import('my-types') as MyTypes}
 *  - Or,
 * @export {import('my-types')}
 */

None of these are supported, but some of the syntax has been discussed / proposed on this thread, and here. Also related is #14377.

=== Manual Declaration Editing ===

Of course, if you are hand-editing the declaration files that ship with your JS, you can add whatever you want.

You could also automate declaration editing; you could theoretically generate the bulk of the files with TSC, and then append the types you want exported (in bulk) with a simple build script.


Issues

Issue with Number Properties

Using a number as an object key is completely permissable in JavaScript, so long as you use bracket notation for accessing. For example:

const levels = {};
levels[0] = 'low';
levels[1] = 'medium';
levels[2] = 'high';
console.log(levels[0]);
// > 'low'
// You can also access by string
console.log(levels['0']);
// > 'low'

However, if you try to document this with JSDoc, you might run into an issue...

/**
 * @typedef {object} Levels
 * @property {string} 0
 * @property {string} 1
 * @property {string} 2
 */

// ERROR: TS: Identifier expected.

It looks like VSCode's integration with JSDoc for type-checking does not like anything other than a standard a-Z character as the leading property key.

Number Property Key Workaround

  • One workaround is to simply define the entire object within the first typedef line:
/**
 * @typedef {{0: string, 1: string, 2: string}} Levels
 */

// No errors! :)
  • Another workaround would be to use an actual TS file, e.g. types.d.ts, and follow my directions on having VSCode pick up on it.
Markdown Source Last Updated:
Wed Mar 20 2024 15:26:03 GMT+0000 (Coordinated Universal Time)
Markdown Source Created:
Tue Mar 10 2020 23:28:08 GMT+0000 (Coordinated Universal Time)
© 2024 Joshua Tzucker, Built with Gatsby
Feedback