Typing .js files with JSDoc

You should consider typing your JS variables

Typescript is ever popular these days, but sometimes it’s still too controversial a reach to put it in a project. It might be that you don’t have time to migrate everything, or it might simply be that you don’t have enough knowledge to comfortably move everything over just yet. Regardless of your situation, you can still get some benefits from Typescript, without adding it as a project dependency.

What do I get out of the box?

I’m only speaking for VSCode users here. If you use any other text editor, I’m afraid I’m making no promises. VSCode uses Typescript under the hood to provide code hints, even in your vanilla Javascript files.

Take the following code:

const numbers = [1, 2, 3]

This is a simple example, but using it, VSCode is able to conclude that the numbers variable has type Array, and therefore if you type numbers. afterwards, intellisense will automatically provide you with a list of array methods. Even more useful in this particular situation is that VSCode reads the signature of your array. In this example, it sees that you are only adding numbers to your array and so assumes that the type is an array of numbers, or number[] as it’s represented in Typescript.

numbers.map(digit => {
  // ...
})

In the above example because VSCode knows numbers is an array of numbers, it automatically types digit as a number. Smart, right? Similarly to the intellisense suggestions for arrays, if you try to do anything with digit, intellisense will recommend Number methods such as .toString() and .valueOf().

Without having to do any extra work Typescript can infer all sorts of things about your code. It can determine if your array contains multiple types, it can help you keep track of the shape of your objects, and their property types. It can even determine function return value types in some cases.

So why do we need JSDoc?

Unfortunately the Typescript engine can’t always infer variable types. It’s at this point that we can nudge it back on path with JSDoc comments.

Take the following code:

function add(num1, num2) {
  return num1 + num2
}

It’s easy to determine the types of num1 and num2, right? They’re both numbers, presumably. The function is called add, which is typically something you do to numbers, and the arguments are called num1 and num2 too. Typescript can’t determine that though. It’s also possible to concatenate two strings with the + operator. In this case, Typescript doesn’t know for sure what you want to do, so it doesn’t bother suggesting anything.

If we add JSDoc comments we can put it on the right track. Typescript will read the JSDoc comments and assuming they’re correct, provide their documentation to intellisense.

/**
 * Adds two numbers and returns the result.
 *
 * @param {number} num1
 * @param {number} num2
 */
function add(num1, num2) {
  return num1 + num2
}

In the example above all we had to do was describe the two function parameters. Since we’ve told Typescript they’re both numbers via the comment, Typescript can now infer that the function return type is also a number. We’ve also added a function description in this example. In most cases it shouldn’t be necessary at all, but this is just to show you how you can provide extra documentation in a manner that VSCode will use to help you. With the description provided above, if you ever call the add function, intellisense will display the function description too. This is extra handy if you need to document something from an external api. In those cases you can use the @see tag to send people off to external documentation.

class Accordion {
  constructor(element) {
    this.element = element
    this.drawers = [...element.querySelectorAll('[data-accordion-drawer]')]

    if (!this.activeDrawer) {
      this.drawers[0].dataset.state = 'active'
    }
  }

  get activeDrawer() {
    return this.drawers.find(
      drawer => drawer.dataset.state === 'active'
    )
  }

  // ...
}

As another example, I’ve written the beginnings of an Accordion class above. Although I’ve called the only constructor argument element, Typescript doesn’t know that this means a HTML element. I could be writing a Node package for all it knows. The most accurate type that can be inferred is that of this.drawers, which is assumed to be any[]. Typescript knows that […<iterable>] will return an array of something, but it doesn’t know what element is, and therefore can’t assume what .querySelectorAll() returns. We can provide typing for everything here with one JSDoc comment.

class Accordion {
  /** @param {Element} element */
  constructor(element) {
    this.element = element
    this.drawers = [...element.querySelectorAll('[data-accordion-drawer]')]

    if (!this.activeDrawer) {
      this.drawers[0].dataset.state = 'active'
    }
  }

  get activeDrawer() {
    return this.drawers.find(
      drawer => drawer.dataset.state === 'active'
    )
  }

  // ...
}

This JSDoc comment on the second line here informs Typescript that element has type Element. Typescript now knows that .querySelectorAll() will return a NodeList of Elements. […<NodeList>] is therefore an array of Element, or Element[].

JSDoc also knows that this.activeDrawer is an Element, because it’s returning the drawer in the active state.

Intellisense is super handy when you’re importing classes and such from various modules. Especially when said modules are from npm packages. With intellisense you can get a lot of information without ever having to leave your text editor to read the docs.

Custom types and importing

Sometimes there’s no intellisense information available for something that you need. For example, you might be pulling something from an API. In these cases you can create custom type definitions with the @typedef tag.

Here’s an example:

/**
 * @typedef Film
 * @property {string} title
 * @property {string[]} actors
 * @property {string} genre
 * @property {number} runtime Runtime in minutes
 */

/** @param {Film} film */
function getFilmTitle(film) {
  return film.title
}

In this example, I’ve documented a few different properties. runtime even has a description, because the property name probably isn’t precise enough to describe it. Since Typescript knows that getFilmTitle is expecting a Film, it knows that the function will return a string.

This is useful, but in my experience when creating these custom types, they’re typically something I want to be able to use in multiple modules.

Let’s move the Film type to a proper type definition file, that we’ll simply call Film.d.ts. We should end up with the following:

type Film = {
  title: string;
  actors: string[];
  genre: string;

  /** Runtime in minutes */
  runtime: number;
}

export = Film

We can now import this definition into any file via JSDoc like so:

/** @typedef {import('./Film')} Film */

From then on we can use the Film definition as many times as we need, in multiple files, simply by importing it wherever we need.

You can import types from JS files too. In the Accordion example above, for example, if I have export default Accordion as the last line, I can use import(‘./Accordion’).default for the Accordion typedef. Wherever I import it, any variable assigned the Accordion type will then have intellisense suggest the properties and methods associated with that class.

How about type checking?

Type checking can be done too. By adding // @ts-check to the top of your JS file, VSCode will report any typing issues it spots with your code.

This is of course extremely useful for catching things you may have missed. For example, if a function changes and is suddenly returning an array rather than an object, Typescript can catch this. In terms of long-term maintenance, this should help calm your fears of breaking something 6 month or a year down the line. You no longer have to remember exactly how everything works.

Of course, type checking won’t prevent you from writing buggy code. It will however reduce that risk. One easy mistake to make is to start passing values of multiple different types into a function that doesn’t account for them.

Take a look at the example below:

/**
 * @typedef Person
 * @property {string} name
 * @property {(string|Person)[]} relatives
 */

/** @param {Person} person */
function getRelativesAsString(person) {
  return person.relatives
    .map(relative => relative.name)
    .join(', ')
}

Here we’re trying to return a string list of relatives, but the relatives property on the Person type can contain strings, which don’t have a name property. Typescript will report this issue.

/** @param {Person} person */
function getRelativesInString(person) {
  return person.relatives.map(relative => {
    if (typeof relative === 'string') {
      return relative
    }
    return relative.name
  })
  .join(', ')
}

If we check the type of relative first, we can safely return the correct value for each relative. Typescript also recognises this, and doesn’t report an error.