Sam Thorogood

Check your JS with TS

TypeScript is great and its static analysis and typechecking can help you be more productive, but swapping to TS wholesale isn't possible for many projects. You may also want to keep your project as pure JS, if, like me, you like not compiling when testing in your browser (just serve your files as ESM and let the browser do it).

So, you want check your JS—and this post uses ES Modules, or ESM—with TS tools. Great! This post has three levels of complexity, so read on.

Basic: Inline types in VSCode

As you hover over symbols in VSCode, you'll see inferred type information: for constants and so on you'll see string and number. This is the type that TS can safely guess. (If you see any, this means that TS can't work out what type you're using.)

You can fill in the gaps here with JSDoc comments which add types. There's a number of ways to specify them:

/** @type {number[]} */
const x = [];  // otherwise TS thinks this is 'any[]'

/**
 * @param {Element} bar
 * @param {?Element} barOrNull
 * @return {Promise<void>}
 */
async function fooMethod(bar, barOrNull) {
  // do something with bar/barOrNull
}

/** @type {(arg: number) => string} */
const fn = (arg) => {
  /* ... */
  return 'done';
};

// this is a _cast_, not a declaration: you need to wrap in parens ()
const nowIsNumberType = /** @type {number} */ (window['someExternalAny']);

Within any @type {...}, you can use a combination of TypeScript's type system as well as much of JSDoc. The possibilities for types are out of scope of this post.

So—this is fine, but all it gets you is helpful information when you hover over a type or you're trying to autocomplete. Let's get more useful feedback.

Intermediate: Write TSConfig

If you create a custom "tsconfig.json" file in the root of your project, you can enable warnings and errors for your project. The file should look something like this:

{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,

    // if you'd like to warn if you're using modern features, change these
    // both to e.g., "es2017"
    "module": "esnext",
    "target": "esnext",

    // configure as you like: these are my preferred defaults!
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,

    // "strict" implies this, but you'll want to enable it when you're
    // ready: it's a huge reason your project will start complaining
    "noImplicitAny": false,
  },
  "include": [
    // include the JS files you'd like to check here
    "src/**/*.js",
  ],
}

You can also use a base config but you'll still need to specify include as well as the top two compilerOptions to ensure we just check JS files.

⚠️ Keen observers might also notice that I've included JS-style comments inside the JSON as well as my favourite syntax feature, trailing commas. TypeScript seems to be completely fine with this extended syntax.

For free: VSCode

Once you create your "tsconfig.json" and make sure it matches your source files, you'll notice something amazing: VSCode will now just start warning you about problems.

VSCode showing a warning about an unused variable
VSCode can now show JS warnings, not even just about types

To be clear: I didn't install any TypeScript tooling to make this happen, it was just implicitly part of VSCode. Nice! 🎉

Command-line: TSC

You can also now run TypeScript via the command-line to get warnings and errors for your whole project, even if it's not compiling your code. Install the NPM package and run its command-line compiler (which will just check, since we set noEmit above):

$ npm install -D typescript
$ tsc

If your project has errors—and trust me, for any project where you've not typechecked before, you'll have them—this will print all of them and exit with a non-zero status.

Advanced: Write/use types

It's all well and good to use types like number and string[], but what if you'd like to define your own types—such as a complex interface type with many properties? There's actually many ways to do this in TypeScript, and some background is useful:

While the first approach is useful for say, external types—you might depend on something in NPM's @types repo or a built-in library—the second is my preferred option for your ESM projects.

Import your types

If you create a file like "types.d.ts", you can actually import it as "types.js" (and VSCode can suggest this in an autocomplete). TypeScript actually prevents you from importing the ".d.ts" directly-you must pretend that it is a JS file. But the JS file doesn't actually exist—how can this interop with other tools and loading in your browser?

Turns out, we can just create two files: one "types.d.ts" for types, and one "types.js" that's actually just empty. These two files might look like:

//
// @file types.js
//
// This is an empty file so that browsers and tooling doesn't complain.

//
// @file types.d.ts
//
/**
 * This isn't a real class, it just defines an expected object type.
 */
export interface ArgForSomething {
  foo: string;
  bar?: number;
};

/**
 * We can define functions, too.
 */
export function exportedFunction(arg: ArgForSomething): void;

And to use the code, in a regular JS file:

import types from './types.js';

/**
 * @param {types.ArgForSomething} arg
 */
export function foo(arg) {
  // ...
}

/**
 * If you export a function from your types, you can also just reference it
 * wholesale: this might be useful if you're publishing to NPM.
 *
 * @type {types.exportedFunction}
 */
export function exportedFunction(arg) {
  // ...
}

Voila—type information!

Importantly, when you're bundling or compiling, tools will hide the dummy, empty file. And during development, the file technically exists, but is ignored since it's empty anyway and is only referenced inside your comments.

Other approaches

I mention the classic approach for completeness, but this post is really about treating ".d.ts" files as modules. Skip this section unless you're really interested.

So, you can reference other files in your own project using the triple-slash syntax. However, it doesn't mesh well with modules: you can't see anything that had export on it in that referenced file, and everything else will be brought into the global namespace. (There's exceptions here too, and it's just more complex than treating it as an ES Module.)

Export types for others

If you're not publishing to NPM, you can stop reading. But if you are building something that can be consumed further, then read on.

By default, TypeScript looks for the "index.d.ts" file in your project's root directory to provide types for the users of your package. In the example above, I've intentionally not used that name as I think creating an empty peer "index.js" in the top-level of your project is probably going to lead to confusion. I like specifically calling it "types".

You can specify a path to your types in "package.json". (Turns out, TypeScript recommends you do this anyway, even if the file is the default name.) This looks like:

{
  "name": "your-awesome-package",
  "types": "path/to/types.d.ts",
  "exports": {
    "import": "./main-module.js",
    "require": "./main-require.cjs"
  }
}

This types file should match your top-level export file. It can import further ".d.ts" files (and these don't need a dummy peer JS file) and even re-export them.

⚠️ As of writing, TypeScript doesn't support subpath exports. There's some workarounds in that thread.

Summary

Your JavaScript can benefit from TS' static typechecking and errorchecking abilities. It can also reveal a whole bunch of errors or risky behavior you didn't know you had—but hey, that's progress for you. The tools you regularly use—including VSCode, but command-line "tsc" too—are so close to being incredibly useful, even for pure JS, and by giving them the right config, you can get a lot more data.

And of course, while static analysis is great, it's also not a replacement for good tests. Go forth and check your code!