JavaScript Type Linting

TruckJS
16 min readOct 21, 2019

--

Type Linting In Action

There is a common minsconception that the only way to achieve type safe JavaScript it is to use a compile to JavaScript language that provides static typing. There are a number of these languages, such a ClosureScript, Elm, ReasonML, TypeScript, etc. Currently the most popular solution for JavaScript type safety is TypeScript.

Another way to implement type safety for JavaScript is with type linting. This simple technique uses Visual Studio Code and its TypeScript language server runing in the background. As you type your JavaScript, TypeScript will analyze the types and report any errors it finds. Since this happens live, there is no need to run an extra build step to test the types — the types are being tested as you type. More importantly, you write JavaScript, not TypeScript.

There are three ways to have Visual Studio Code type lint JavaScript: 1) at the file level, 2) globally for all projects, and 3) for a specific project only.

Enable JavaScript Type Linting

At the file level:

If you are using Visual Studio Code, you can enable type linting in several ways. The easiest is to put the following comment at the very top of any file you want to type lint:

For all projects:

If you find this kind of annoying, you can enable type linting for all your projects by opeinging Visual Studio Code’s settings. Search for checkjs and set it to true:

This will enable type checking for any JavaScript file you open in Visual Studio Code.

For a specific project:

If you don’t want type linting on all of your projects, you can limit it to a specific project with a setting.json file. To do this, create a .vscode folder at the root of the project. Inside that folder create a settings.json file and in that provide the following:

Of the three ways to turn on type linting, I tend to prefer using the settings.json file since it covers all the JavaScript files in the project. But then, you may find that TypeScript is checking files that don’t need type checking, such as a gulp file, or some other build script. You can turn off type linting on a single file by putting // @ts-nocheck at the top of the file. This needs to be the first line of the file, before even the imports:

// @ts-nocheck
const gulp = require('gulp')
const rollup = require('rollup')
const babel = require('rollup-plugin-babel')

Type Inference

When you set up type linting in any of the ways described above, Visual Studio Code uses its TypeScript server to parse JavaScript files and understand their types. By default it uses type inference to do so. That means that if you have a variable declaration for a string or number, its type will be considered that:

Many strongly typed languages use type inference as a short cut to declaring types. TypeScript itself also supports this. But this is not a failsave means to determining types. In fact, when in doubt TypeScript will treat most unknown types as any. This is not good. When types get coerced to any it defeats the purpose of type checking.

JSDoc Comments for Types

You can provide type information for TypeScript in your JavaScript using JSDoc comments. These are simply valid JavaScript comments — they don’t require the JavaScript to be compiled and they don’t affect how your code runs. You can run JavaScript with JSDoc type comments in a browser or on Node — no problem.

What this approach provides is exactly the same as TypeScript — rich Intellisense and warnings about improper use of types. Enter the wrong type in a function and you’ll get a warning in Visual Studio Code just as you would if you were writing TypeScript. This obviates the need for a d.ts file, since that comments and types that it would provide now reside in the JavaScript itself.

JSDoc was created to document JavaScript. How it does so therefore feels like a better fit than the approach that TypeScript takes with type annotations, interfaces and other structures that don’t exist in JavaScript. Because TypeScript follows patterns common in C# and Java, it doesn’t feel or look like JavaScript. It looks like Java or C#. If you like those languages, you’ll probably like TypeScript. JSDoc doesn’t change how your JavaScript looks. You’re writing normal JavaScript with comments that happen to define the types your JavaScript is using.

TypeScript is a language that compiles to JavaScript. It purpose is to provide type safety up to and ending at the build stage. After that, you’re left with JavaScript. All that hard work to provide type safety through TypeScript will vanish once you have JavaScript running in the browser or Node. As such, the only guarantee against type errors for JavaScript while it’s running is to provide type guards. If you’re really paranoid about a type error breaking your app as you mentioned, you would use a type guard in your JavaScript code. A static type check is not going to be sufficient to prevent an error during runtime.

In contrast, JSDoc type information is valid JavaScript comments. As such you can leave them in your code if you want. They will not affect how your code runs. Generally, though, you’ll want to minify your code, which usually eliminates comments.

Examples of Type Safe JavaScript

To show you what type safe JavaScript with JSDoc comments is like, I’ll use an NPM package I published called @composi/core. It’s written in modern ECMAScript 2015. It has no d.ts file but provides type information via JSDoc comments. When this module is imported into a project, all its type information becomes available to the end user.

In the following image, you see at the top functions being imported from @composi/core. When hovering the cursor over the h function, you get a popup showing the type structure and the expect arguments and what their types should be.

In the following image, we hover over the render function. The popup again tells us about the type structure and the expected arguments. Note that VNode and container are required arguments, but the third argument, hydrateThis is optional.

In the next image we see the result of trying to use the render function with wrong arguments. In this case, since it’s missing the second argument for container, we get that warning. We also get two options at the bottom of the popup to peek at the problem or for a quick fix. Notice that at the top it clearly says the render function expects 2 to 3 arguments, but we only provided one.

Also notice that there is the file and line number and character position — all in blue — of where the requirement for container is in the original source code.

In the next image we see the result of hitting Peek Problem.

If we click on the blue text with the line number of the source code, Visual Studio takes us to the source code of the imported module to show us what that functions arguments are. Here is that image.

In the next image we provide a second argument. Now the warning has changed. We are infomed that a number of 123 is not a valid argument for the render function since its first argument should be a VNode type.

In the next image we fix the first argument to be a valid VNode. But now that second argument is flagged because 123 is not a valid argument for a container.

In this final image we fix the wrong type by providing a VNode. Now we see that the second argument we provided for the container is the wrong type:

As you can see, it is possible to have type safe JavaScript without resorting to a compile to JavaScript language. This is thanks to how TypeScript and Visual Studio Code work together to provide type linting for JavaSript. So, yes we are using TypeScript, but in the background as a type linter. Our code remains JavaScript through the entire development process. We never write TypeScript — we write JavaScript. But TypeScript is able to understand the JavaScript types thanks to JSDoc comments.

I truely believe that providing type safe JavaScript through JSDoc comments offers immense value to the JavaScript ecosystem without having to switch to a compile to JavaScript solution for type safety.

Defining JavaScript Types

JSDoc allows you to document the types your JavaScript is using. Unlike other type solutions that impose an artificial type system on top of JavaScript, JSDoc sticks to the basics. JavaScript has types, but it is loosely typed and assigns them dynamically during runtime. Dynamic typing means that JavaScript does duck typing — if it walks like a duck and quacks like a duck, treat it like a duck. This means that if two entirely different objects share certain properties, you can use them interchangeably as long as you use the only the properties they share. JavaScript often accomplishes this by calling or accessing a property of an object’s parent through its prototype chain. The full range of types that JSDoc defines are enumerated on this website. What follows is a quick summary.

Primitive Types vs Objects

JavaScript has the follow primitive types:

  • undefined
  • null
  • boolean
  • number
  • string

JavaScript type primitives are immutable. And they are compared by value. All non-primitive values are objects. These are mutable and are compared by reference. The two simple types — string and number — have object versions. Other data structures in the language are objects, such as objects, arrays, functions, classes.

Basic Types

JSDoc is just a convention of standard JavaScript comments, usually with this format:

/**
* Your comment goes here...
*/

Within those comments it uses specific formats and terms to define JavaScript types.

@type{}

The most important JSDoc tag is @type followed by curly braces {}. When you indicate a type, it will always be enclosed in curly braces.

/** 
* A simple string example.
* @type {string} name
*/
const name = 'Joe'

Similarly, we could define an age with a type of number:

/** 
* A simple number example.
* @type {number} sum
*/
let sum = 2 + 2

When indicating types, you can use the capitalized or lowercase version. The current convention favors lowercased versions.

Type “any”

When linting your types, when TypeScript is unable to determine a type, it treats it as type any. This is not a valid JavaScript type but is the means by which TypeScript flags a value to not be checked. You can also flag a type as any. Why would you do that? For those rare situations where you’re not sure what type something will be. This arrises due to unexpected type coersion that JavaScript does on occasion. There are several ways to indicate type any:

/**
* The following are all equivalent:
* @type {any}
* @type {*}
* @type {?}
*/

Notice: an unexpected way of indicating type any is with the object keyword:

/**
* The following actual result in type any:
* @type {Object}
* @type {object}
*/

This is a decision made by the TypeScript team after examining how these two terms are used in JSDoc comments in code repositories over the years. Because developers have used the term object carelessly it often does not actually indicate a true type Object. For this reason, when TypeScript sees a JSDoc type declaration of object, it treats it as type any.

If you’re wondering how you indicate an object type, we’ll explain that next.

Complex Types

Complex types are, well, complicated. As mentioned above, just using the type Object or object results in a type of any. If you want to actually indicate an object type, you need to get more specific. How you indicate an object depends on the nature of the object. If you are dealing with an empty object literal, then you use that as the type:

/**
* Here we have an empty object literal.
* @type {{}} obj
*/
const obj = {}

Defining a type as an empty object literal is fine, as long as you don’t intend to add properties to it latter. If you do, you’ll run into problems because your type is an empty object literal:

/**
* Here we have an empty object literal.
* @type {{}} obj
*/
const obj = {}
obj.name = 'Jane' // Error, property name does not exist on obj.

You can get around this problem by letting TypeScript know the properties on the object literal:

/**
* Here we define an object literal type with two properties:
* @type {{name: string, age: number}} obj1
*/
const obj1 = {} // Error, missing properties name and age.
/**
* Here we define an object literal type with two properties:
* @type {{name: string, age: number}} obj1
*/
const obj2 = {name: 'Sam', age: 32} // No problem here.

Open-ended Object

You can avoid these problems with objects by defining an open-ended object. This is basically telling TypeScript to treat the object as a map:

/**
* Define an open-ended object.
* Here we're saying its properties will be of type string by default:
* @type {Object<string, any>} obj
*/

If you intend to use numbers as the values of your object, then you would indicate the number type for the properties:

/**
* Define an open-ended object.
* Here we're saying its properties will be of type number by default:
* @type {Object<string, number>} obj
*/

Defining a value as an open-ended object means we can dynamically add or call properties on it without a problem:

/**
* Define an open-ended object.
* Here we're saying its properties will be of type number by default:
* @type {Object} obj
*/
obj.name = 'Same' // No problem adding a property.

Although open-ended objects solve the problems we saw with object literals, they don’t provide us with any useful information for their properties. All new properties are being treated as type any. To get stricter type information for object properties you’ll need to create a custom property. How to do that will be explained further ahead.

Arrays

We often deal with data as arrays. JSDoc makes it easy to define the types of arrays we deal with. The simplest way to indicate an array is to indicate the type the array holds, followed by brackets:

/**
* An array of undetermined types:
* @type {any[]} arr1 // You could also use {*[]}
*/

We could also indicate an array of strings or numbers or objects:

/**
* An array of strings:
* @type {string[]} arr2
*/
/**
* An array of numbers:
* @type {number[]} arr3
*/

You could indicate an array of generic objects like this:

/**
* An array of objects:
* @type {Object<string, any>[]} arr4
*/

Further ahead when we see how to create custom types you could indicate an array of those as well.

Functions

The simplest way to indicate a function type is with @function tag:

/**
* A simple function.
* @function
*/
function doIt() {}

Function Parameters

Usually functions have parameters. You indicate a function parameter with the @param tag. This takes a type in parenthesis followed by the parameter name:

Callbacks

You can define callbacks with arguments as well. Check out the documentation for callbacks.

Classes

Since classes are complex, better to just consult the document for classes.

Custom Types

Sometimes you will need to define a custom type to define what you code is doing. TypeScript uses interface to define custom types, like Java and C#. JavaScript does not have interfaces, but you can achieve the same effect by using the @typedef tag. You use this to define the base for the custom type and then use @prop or @property to define properties on it. Let’s create a custom type called Person:

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

This gives us a custom type Person, which we could then use like this:

/** 
* Analyze a Person object.
* @param {Person} person
*/
function analysePerson(person){
alert(`The person's name is ${person.name} and the age is ${person.age}`)
}

Because we’ve indicated that the function’s argument is an object of type Person, we can be sure that we will find the correct properties of name and age.

Import Types

If you have a type already defined somewhere, you can import that type into another file to use there. You will have to use relative paths. To import a type. use the format import('path').Type where path is the path to the file that contains the type. After the closing parens put a dot followed by the name of the type. TypeScript will be able to import that type for you to use in your JavaScript.

/**
* @typedef {import('../types').Vehicle} Car
*/

Optional Types

Sometimes a parameter or object property needs to be optional. You can indicate optionality by inclosing the type name in brackets. In the following example the age property is optional:

/**
* @typedef {Object<string, any>} Obj
* @property {string} name
* @property {number} [age]
*/

We can also do that with parameters. In the following example, the user can leave out age without a warning from type linting:

/** 
* @param {string} name
* @param {number} [age]
*/
function makePerson(name, age) {
return {name, age}
}

Union Types

Sometimes a value could be of various types. This is a problem when dealing with function or method parameters. We can indicate a union type of multiple possible types by separating them with a pipe:

/**
* @param {string | number} age
*/
function announceAge(age) {
alert(`The person's age is ${age}`)
}

This allows the user to provide a string or number as the argument for announceAge.

Type Coersion

Type coersion takes place in your code. This happens when a value’s type is ambiguous and TypeScript can determine the methods you are calling on it. To do type coercion, you need to embrace the property in parens, then precede it with a type definition. In the example below we have a variable sum that my be of ambiguous type. Trying to use toFixed on it could be flagged as an error. To get around this we can coerce it to a number.

/** @type{number} */ (sum).toFixed(2)

Skip a Line

If you’re having too much trouble trying to get a type definition right, you can disable an individual line by preceding it with the following comment:

// @ts-ignore

Reducing Verbosity of JSDoc

TypeScript users in particular don’t like the idea of using JSDoc because they say it is so much more verbose than TypeScript. As long as you are not trying to use your JSDoc comments to create documentation, you can make your custom types and comments in general less verbose by eliminating the unnecessary * at the start of each line.

Here’s an example of some complex type definitions using a more compact version of JSDoc comments:

And below are the same types defined in TypeScript:

As you can see, there is not that much difference between the two approaches. JSDoc compact looses syntax highlighting, but it is readable. This really comes down to preference. Do you want to be able to just write JavaScript and have type safety? Use JSDoc comments as described here. Do you want to use another language that provides type safety and compiles down to JavaScript? Then there’s TypeScript, Reason, Elm, etc.

Setting Up a Project

To help you get going with a project that supports both live type linting and type linting at build time I’ve created a basic project on Github call check-js. This provides project-based type linting as well as type linting during the build process. To be frank, the project is quite basic. It provides support for ESLint, Prettier, Jest unit tests and type linting. You can customize ESLint, Prettier or change what you use for unit tests to your preferences. The project is here:

tsconfig.json

You can help Visual Studio Code understand the structure and types of your project better by including a tsconfig.json file at the root of your project. Here’s an example of one:

Notice that the path is indicated as src/index.js. You would want this to be whatever path exposes your JavaScript. Also notice the excludes property. This allows you to tell TypeScript to ignore certain files and directories. You definitiely want to ignore your node modules. Since this configuration automatically creates d.ts files from your project’s source code, putting them in a folder called types, you also want TypeScript to ignore that folder. Since I usually have my tests in a folder called __tests__, that’s excluded. If you have tests, change this to whatever the folder with your tests is named. With the setting in the above tsconfig you can run a type check on your project from the terminal with the following NPM script:

"scripts": { 
"checkjs: "tsc"
}

Besides running a type check on your code, this will also produce TyepScript defnition files (d.ts). You can update you package.json file to expose these types to your users by added a type specific entry:

typings: "types"

Of course you would need to have TypeScript installed as a dependency first. To get all the above features you need to have at least version 3.7.3:

npm i -D typescript@latest

Then you can run the follow:

npm run checkjs

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
*/

Examples

And, for those who want to see some solid implementation of type linting, check out the projects in this repository. They have real time type linting, as well as a type test that you can run from the terminal with npm test:

Further Reading

I also wrote an extensive article on JSDoc types for JavaScript called Type Safe JavaScript with JSDoc.

--

--