How to avoid a potential bug with JSDoc + @typedef

With no build-step in sight, it can be tempting to put more and more type information into comments.

Consider the following type definition:

/**
 * @typedef {'css' | 'xpath'} SelectorKind
 */

This declares a new type alias for a union of the literal types 'css' and 'xpath', it can then be used in parameter position like so:

/**
 * @param {SelectorKind} kind
 * @param {string} selector
 */
function matches(kind, selector) {}

matches('css', '.heading') // βœ…
matches('xpath', '.heading') // βœ…
matches('oops!', '.heading') // ❌
        ^^^^^^
Argument of type "oops!" is not assignable to parameter of type "css" | "xpath

In a Typescript file, the same thing would be:

type SelectorKind = 'css' | 'xpath'

function matches(kind: SelectorKind, selector: string) {}

They both provide the same level of type-safety + DX (autocomplete), but the JSDoc version suffers from a possible edge-case that I encountered for real recently πŸ‘€...

All about the formatting

A common formatting technique to apply when a Typescript codebase starts to grow:

type SelectorKind =
   | 'css'
   | 'xpath'

It's good that Typescript supports the leading | here, it allows these union types to grow and remain nicely aligned πŸ‘Œ

It's even more common when listing out things like events:

type Events =
  | { kind: 'login'; user: string; password: string }
  | { kind: 'logout'; }

Now, trying to apply this back to JSDoc, the first attempt might be:

/**
 * @typedef {
 *    | 'css'
 *    | 'xpath'
 * } SelectorKind
 */
  • ❌ oops! We've just introduced a subtle bug!
  • Because the very next character following * @typedef { is a new line, it ends up causing SelectorKind to have the type any!

The most worrying part of this potential bug, is how it silently widens the type to any, whilst still keeping the type defined in scope.

It means that any function that previously used SelectorKind before we re-formatted will still appear to be valid, for example:

/**
 * @param {SelectorKind} kind
 * @param {string} selector
 */
function matches(kind, selector) {}

matches('oops!', '.heading')
//      ^^^^^^^ this should be an error, but isn't!
  • on line 2, this type reference is still valid - Typescript will ensure we're referring to a type it knows about.
  • but, line 7 no longer causes a type-error, even though it did before 😭
  • because SelectorKind was accidentally widened to any, matches will now accept any argument - a random string, a number, anything!

To resolve this problem, you just need to ensure you place a character from your type directly after the opening {, for example:

/**
 * @typedef {|
*       'css'
 *    | 'xpath'
 *    | 'abc'
 *    | 'def'
 * } SelectorKind
 */
  • 🀒 I added a few more strings to show the value of the alignment, but now the absense of the | to the left of css just makes this even more confusing.

Here's another format:

/**
 * @typedef {'css'
 *    | 'xpath'
 *    | 'abc'
 *    | 'def'
 * } SelectorKind
 */
  • This also works, but now we've lost the alignment altogether! 😭

Regardless of what can work though (there is likely more combinations), let's get back to the point of this post...

Why this matters

I think it's dangerous how a single newline character can convert a previously type-safe implementation into one that now has an any hole in it.

My real-world scenario played out as such:

  1. I started out with a simple type, and then used it inside a function parameter.
  2. Then, I added a few more variants, until the line-length got too long for my liking.
  3. So, I split the type onto multiple lines for readability...
  4. πŸ’₯ silently, Typescript had demoted my union of string literals into any, essentially removing all type-checking.

Other Solutions

(1) Using Typescript files alongside .js

You can retain your no-build setup whilst still benefiting from more elegant type definitions when needed. Just stick to JSDoc and then import from .ts files as needed.

/**
 * @param {import("./types.ts").SelectorKind} kind
 * @param {string} selector
 */
function matches(kind, selector) {}
// types.ts
export type SelectorKind =
   | 'css'
   | 'xpath'
  • The trick with this approach is to only ever put types in your Typescript files - this prevents having to transpile anything.
  • Now you can format your types without any of the drawbacks seen in @typedef
  • No matter the formatting, inside a TS file this type will never be widened to any by mistake 😍.
  • Also, if you need to reach for more complex features, like mapped types, you can also place those in the .ts files too. (something you literally cannot do in JSDoc alone)

(2) Deriving Types from JavaScript code:

In simple cases, you may be able to derive these kinds of types directly from Javascript code instead. The example in this post is deliberately small for brevity, but it would be a good example where you could drop the @typedef altogether, for example:

const SELECTOR_KINDS = /** @type {const} */ (['css', 'xpath'])

/**
 * @param {SELECTOR_KINDS[number]} kind
 * @param {string} selector
 */
function matches(kind, selector) {}
  • This approach has some nice properties when the types are simple (eg: lists of strings)

It does however, come with its own tradeoffs: get the parens incorrect, and it'll also accidentally widen to any

// βœ… this has the type `readonly ["css", "xpath"]`
const SELECTOR_KINDS = /** @type {const} */ (['css', 'xpath'])

// ❌ this has the type `string[]`
const SELECTOR_KINDS_2 = /** @type {const} */ ['css', 'xpath']

Summary

When working with TypeScript via JSDoc, it can be tempting to reach for patterns you've used or seen in .ts files from other projects.

If adding them inside JavaScript comments feels awkward, consider one of the solutions mentioned above.

A simple rule to apply is as follows: If your types become complex (like unions) and cannot be represented in a single line, consider moving it to a .ts file or deriving it from code instead πŸ‘Œ.