Type Safe JavaScript with JSDoc

TruckJS
15 min readSep 4, 2018

--

Generally developers believe that you need TypeScript or Flow if you want type safety for JavaScript. This article we will explore a third alternative to achieve those same goals using just JSDoc comments and Visual Studio Code as your editor. Technically, we are going to use TypeScript but not for compiling our code. Instead we’ll use it to check the types of our JavaScript code during code time using JSDocs comments and type inference. So there’ll be no need to compile the code to JavaScript. You might have to use Babel, depending on what version of JavaScript you’re using. Or you could just use good old ES5 with this technique. In that case no build would be necessary, other than for minification.

Types are Good

Types provide valuable information about the nature of code, and help identify typos, enable refactoring, etc. Most editors these days provide some kind of IntelliSense based on type information gleaned from the code. My favorite is Visual Studio Code. With proper type information it can give you symbol defintions on hover, code completion, symbol renaming across files, etc.

Benefits of Types

  1. Early detection of type errors
  2. Better code analysis
  3. Improved IDE support
  4. Promotes dependable refactoring
  5. Improves code readability
  6. Provides useful IntelliSense while coding

These benefits are provided by using TypeScript, Flow and even JSDoc comments.

Getting Types Right is Hard

It takes time and experience to be proficient with types. If you have a background in a language with strict typing like Java, C#, etc., the transition will be easy. A type system for JavaScript is going to be less sophisticated than that provided by strongly typed languages. If you’re accustomed to a language with dynamic typing, like Python, Ruby or JavaScript, providing types for JavaScript can feel burdensome. If you’ve never used type checking before, be prepared from some shock. Remember that first time you used jslint or jshint on your code? This is going to be much worse.

Type checkers are inhuman and lack empathy for their users.

Type checking for JavaScript can sometimes be intimidating.

Be Proactive

If you find yourself spending too much time trying to get your types right, you might want to re-think how you’re implementing them. Using JSDoc comments is the easiest way to provide type information for JavaScript and get on with coding.

If you’re new to using a type checker with JavaScript, it’s good to understand how they work. This is a good basic explanation about how Flow checks types.

Don’t assume that because your code passed type checks during build time that there will be no type errors. Type errors that happen during runtime can be avoided by using type guards at appropriate places. In general, type errors are going to be the smallest number of bugs that you will have to deal with. For your users, UI/UX inconsistencies will be the things that drive them up the wall. Management will label UI bugs as Pri-1, and your type errors will at best be Pri-3 or else backlogged.

JSDoc for Types

JSDoc provides type information as comments in JavaScript, so in that sense it is more similar to Flow than TypeScript. The difference is that JSDoc comments are standard JavaScript comments. So, no need for a build step to transpile to JavaScript or a plugin to strip the comments out like Flow. When you minify your code, the JSDoc comments get removed automatically.

Unlike with TypeScript, with Babel you can live on the cutting edge using stage 0 - 2 presets with JSDoc. If you use Visual Studio Code, you will get advanced IntelliSense from your JSDoc comments. In fact, the great IntelliSense that TypeScript provides through d.ts files is because these also include JSDoc comments.

Set Up Visual Studio Code

To get the the most out of JSDoc with Visual Studio Code, you need to do some setup. In Visual Studio Code, go to Preferences > Settings and add the following line to your user configuration:

"javascript.implicitProjectConfig.checkJs": true

With plain JavaScript this will give you some basic IntelliSense and flag type errors with red squiggles underneath:

Red squiggles indicate a type problem.

Hovering over the flagged type error will popup an explanation of the problem:

Message about wrong type usage.

Providing JSDoc comments to describe your code’s types will resolve these kinds of type errors.

Alternative Ways to Turn Type Checking On and Off

You can also skip the user settings and tell Visual Studio Code to check a single file by putting // @ts-check at the top of a file.

If your user settings are set to check JavaScript by default, you can opt out for an individual file by putting // @ts-nocheck at the top of the file.

If you’re really having troubling dealing with the types on one line of code or the block of code beginning at that line by putting this right above the line: // @ts-ignore.

If your project has a jsconfig.json file, you can add checkJS to the compiler options to turn on TypeScript checking for JavaScript:

Setting Up Live Type Linting

Here’s an article that details setting up live type linting for JavaScript projects:

JSDoc Comments

If you are not familiar with JSDoc, you can learn more from the following links:

  1. JSDoc: Main Site
  2. JSDoc: Getting Started
  3. JSDoc: ES6 Classes
  4. Visual Studio Code: JSDoc Syntax Support
  5. Visual Studio Code: IntelliSense based on JSDoc
  6. Visual Studio Code: Supported Features of JSDoc

Defining Types with JSDoc

Here is a brief summary of the features for types provided by JSDoc:

Indicate a Type:

Use @type to indicate a type:

Possible values for type are number, string, undefined, Array, Object. With arrays, you can optionally use a typed array: any[], or string[], or number[], or Object[].

You can also define union types and intersection types:

Default Types

JSDoc provides the following types:

  1. null
  2. undefined
  3. boolean
  4. number
  5. string
  6. Array or []
  7. Object or {}

You can have a typed array: any[], number[], string[]. You could also have an array of object types: Employee[].

Custom type

Use @typedef to define a custom type. This is something like a TypeScript interface. After defining a custom type, you can define properties for it.

Properties

Use @property to define an object’s members.

Hovering over person will give us the following type information:

IntelliSense for person object.

Hovering over the name and age properties gives us the following information:

IntelliSense for name property.
IntelliSense for age property.

Proper JSDoc type comments can inform the TypeScript engine precise information about the code, which results in advanced IntelliSense, as illustrated above. It’s not just types, it’s information describing the object and its properties.

Method or Function

JSDoc has @function or @method to define functions or class methods. Usually you don’t need to use these. For a function it is enough to indicate its parameters and return type. Visual Studio Code’s JavaScript Language Service can already tell when a code block is a function or class method. However, if an object member is a function or if I define functions in a class constructor, then I define them as properties of type function:

And here’s a class constructor with a method:

In the above gist, when we hover over guy.sayName we get the following IntelliSense:

Optional Parameters

Use [] around a property name to indicate that it is optional. Although JSDocs has several ways to indication optionality, this is the only technique that works with Visual Studio Code and TypeScript. Here is the user code but with the age marked as optional. Notice the parens around the age property name. Because it’s optional, we can leave it off the actual object we create. When typing, it will be offered as an option for code completion:

Notice how when we type a period after opts we get a popup with possible completions. You can use the up and down arrows keys to scroll through the available types. As you do so, the highlight option will show its type information. In this case, age is highlighted, indicate through the question mark that it is optional. We can see its a property and should be a number. The JSDoc comments made this information available to the text editor’s type engine.

Notice the ? after age. That indicates it’s optional.

Note: The latest version of TypeScript now understands Google Closure compiler syntax for optional parameters/properties. The syntax is different. It uses the = after the type definition. Notice how the = operator is place at the end of the types for age and job. There should be no space.

Expando Properties

There is another problem with type checking and adding properties dynamically to an object, as explained below:

Because the new language service is powered by static analysis rather than an execution engine (read this issue for information of the differences), there are a few JavaScript patterns that no longer can be detected. The most common pattern is the “expando” pattern. Currently the language service cannot provide IntelliSense on objects that have properties tacked on after declaration.

The only way to deal with expando properties to to escape them with braces and quotes:

Defining Object Types

In JSDoc usage for types, type Object and object are treated as any. I know, this sounds illogical. After examining usage of JSDoc in the wild, the TypeScript team came to this conclusion. That gives you two choices when you need to actually have a Object type, use an object literal or use Object with types:

Out of the four ways to define an object, the first two are the least useful. They will have the same problem of expando properties that we discused earlier. The third is fine for simple objects. But the fourth approach is the most flexible because you can use it for objects with many different properties. And its more verbose property definitions result in much richer IntelliSense:

And here you see the IntelliSense we get from this:

We could have gone with a simpler approach, which would work for types but would provide less useful IntelliSense:

Although this is type correct, the resulting IntelliSense is very minimal:

Verbosity vs Simplicity

When it comes to how you define your types, it’s best to be more verbose as shown in the previous example of object types because it will result in richer IntelliSense. But if the code will not be used by others ever, then simplicity is good enough. Bare in mind that if you use simpler definitions, and then come back to the code many months later, you may have a harder time sorting out what the code is doing. Verbose code not only contains type information but documents how the code works for human readers. It’s best to err on the side of being verbose.

Generics

Yes, you can even have generics. Use the @template tag to define a generic:

Type Casting

Often you need to do type casting to resolve problems with type coercion that static type checkers can’t understand. You do a type cast by enclosing the element to be cast within parens, then before it provide the type information to cast to. The format is:

/** @type{some type here} */(an element to cast)

Typically you have to do type casting when dealing with DOM nodes. The reason for this is simple. TypeScript is a static type checker. Checking occurs as you code and during build time. In contrast, when the code loads in the browser, the browser can do automatic type coercion, from Node to Element or Element to Node. They have different interfaces. You can get a node and invoke Element methods on it. The browser will handle the coercion for you. On the other hand, a type check is going to see that you’re treating one type like another and will flag it as an error. Type casting lets the type checker know that the type should be coerced.

And here’s a gist showing how to do type casting.

Importing Types

You can reuse custom types across your files by importing them from where they are initially defined. Say we have a file in which we have a function that creates a virtual node. In that file we define a virtual node type with a JSDoc comment. We import the function in other files to create virtual nodes. Here’s what our type definition might look like:

Suppose we’re in another file where we’ve imported the createVirtualNode function. We now need to deal with the fact that we are creating virtual nodes and need to show their type. We can import the type definition from another file as follows:

Notice how we first define a custom by importing its definition from the file vnode.js, then we can use it to define the return value for the function.

Batch Imports

If you are using TypeScript version 3.7.3 or later, you can batch import your types. I like to put all the types for a project in a file called types.js at the root of my JavaScript folder. Then when I need to import a bunch of types into another file, I can do this:

import * as MyTypes from '../types'

Use whatever namespace works for your project. Then you can access your types off of that namespace:

/**
* @typedef {MyTypes.Person} Person
* @typedef {MyTypes.User} User
* @typedef {MyTypes.Business} Business
*/

Avoid Type Any

When your new to typing JavaScript it can be tempting to get rid of the red squiggles by giving the offender a type of any. Before doing that, think about what the usage is. Rather than any, you might want to use a union type or intersection type:

For sure there will be situations, like type casting, where you will have to use type any. You could also just use *. Its the same meaning, just shorter.

Checking Types During Build

When your done providing type information with JSDoc comments, you can have TypeScript run a check on it at build time with an NPM script:

Then running npm run checkjs will verify your types. Any errors will be logged in the console along with the line number:

`Type error on line 250 in file lib/vdom.js

We can fix this error with type coercion, letting the type checker know that newVNode.type, which is usually a number, should be treated as a string here:

JSDoc Comments = Type Information++

TypeScript users often complain that JSDoc comments are more verbose than TypeScript types. They are, but for a reason. They provide more information, such as a description of what the types are, what a function does, etc. This provides richer IntelliSense than just types. TypeScript definition files use JSDoc comments to provide the great IntelliSense in IDEs that TypeScript users brag about. Putting JSDoc comments in JavaScript gives you the same experience, but it’s in place where its relevant rather than in a separate file.

To be frank, TypeScript’s and Flow’s type systems are much more sophisticated than what JSDoc covers. But then, JavaScript itself is a loosely typed language. Trying to force JavaScript to follow the strictness of TypeScript and Flow semantics can sometimes feel contrived. JSDoc is a more natural fit for the way the language works.

Here’s an example of how Microsoft uses JSDoc comments in their TypeScript definition files to provide better IntelliSense. This is from lib.es5.d.ts:

Type Safety at Runtime

Neither TypeScript, nor Flow nor JSDoc can ensure that there will be no type errors during runtime.

Type checkers only work during code time or build time. What happens during runtime is another story. Browsers have bugs that may surface and affect your code. The libraries and frameworks that you are using will also have undetected bugs. And the web is a hundred thousand times wilder than the Wild West. Smokey the Bear famously said: “Only you can prevent forest fires.” As a deveoper, only you can ensure there are no type errors during runtime by using guards at critical places. Preventing type errors during runtime is particularly difficult when you are using third party libraries and data from diverse sources. Guard your types.

Only you can prevent type errors at runtime!

Whenever you write a function that expects a type, check it before using it.

A non-string value will result in an unexpected render result.

The above function looks fine, but what happens if somehow the value that gets passed in for newTitle is an object or array? We can refactor this function to check for a string:

Check the type first, if it’s wrong, log the problem.

Using conditional logic, you could test for different types and do different things depending on the type. At the end of the day, implementing type guards is the only way to ensure type safety in JavaScript. Using type guards also allows you to output logs or error messages with information to help identify what the problem is.

You can write type safe code that passes linters and type checks but has egregious security vulnerabilities and fatal logic errors. Whether a value is a string or number is going to be the least of your problems.

tsconfig.json

You can also add a tsconfig.json file to your project. This has several benefits. In better informs TypeScript how to handle the files in your projects, parsing only the only the ones you indicate while ignoring others. It also directly TypeScript to automatically generate d.ts files from your JavaScript source code based on the type information you defined in your JSDoc comments. Here an example of what I usually use:

The outDir directive tells TypeScript where to put the d.ts files. The exclude directive tells TypeScript what files/folders to ignore. You always want to ignore your Node modules. You also want to ignore the folder in which this puts the d.ts files: types. I put my tests in a folder named __tests__, so I ignore that. You can change that to whatever you name your folder of tests.

With such a configuration set up, you can simplify the package.json script for type checking to just:

"checkjs": "tsc"

The above features require the latest version of TypeScript, at least 3.7.3, so make sure to update it like this:

npm i -D typescript@latest

Summary

JSDoc provides a compelling solution for types in JavaScript.
  1. You can get the development experience that static type checkers provide by documenting your code with JSDoc comments and using Visual Studio Code with JavaScript type checking enabled. This provides you with most of the features of static typing without requiring a separate build step. In Visual Studio Code, JSDoc comments will enable code completion, showing symbol definitions, symbol renaming across files, flaging of typos and unused variables, incorrect types, code completion, etc.
  2. TypeScript is a compiler that converts the code to JavaScript. Flow is a checker that uses the type information to check the code. A Babel plugin removes the Flow types during build time. Using JSDoc with TypeScript requires no compilation. Instead TypeScript is used to check the code based on the types provided by JSDoc comments. Thus this is more like using Flow.
  3. Dealing with the DOM can be annoying whether using TypeScript or JSDoc for types. Brackets and quotes will be required for expando properties.
  4. If you’re a large team writing JavaScript, by using Visual Studio Code with JSDoc you can ensure that the code being produce has correct types and provides robust IntelliSense to help new developers.
  5. Real type safety means checking your types at runtime. Use type guards where appropriate.
  6. You need to think ahead about how to handle wrong types when they occur. Should you log an error, alert the user, branch the logic to use it, or ignore it altogether?
  7. JSDoc comments provide valuable type information for your code. They are just standard JavaScript comments. That means your code is valid JavaScript ready to run. During minification the JSDoc comments will be automatically removed, including type cast parens and brackets for escaping expando properties. You don’t have to do anything special to remove JSDoc comments from the final minified code.
  8. If you’re a JavaScript developer who would like type safety and IntelliSense for your code but hesitance to go all out for TypeScript or Flow, JSDoc provides a third alternative path with less resistence. Just open up Visual Studio Code, turn on type checking for JavaScript and start adding JSDoc comments to your code.
  9. If you have a background in Java or C#, TypeScript may be a better choice because it’s closer to what you are used to. Nothing wrong with that.
  10. Type problems flagged by TypeScript, Flow or JSDoc will not necessarily generate errors when run in the browser. Although the difference between a string and a number is significant for type checkers, browsers can easily coerce these as needed. Similarly, for code expecting a string, the browser can cast an array of primitive types into a string. However, writing code that depends on JavaScript type coercion can have unexpected results. It’s always better to be explicit and convert values to the type you need.
  11. TypeScript has a number of syntax features not present in JavaScript: interface, public, private, implements. These make JavaScript feel more like Java or C#. Neither TypeScript or Flow can be used in the browser because they mutate the JavaScript to introduce type information. In contrast JSDoc was designed to describe how JavaScript works. It is, after all, a documentation system. It uses standard JavaScript comments to introduce type information and does not mutate the code. JavaScript with JSDoc comments can run in the bowser. JSDoc is for all practical purposes the standard way of commenting JavaScript.

Type Linting for Dummies

I publish an additional article about how to set up a project for type linting, which you can read as well: type linting.

--

--