Using Typescript in node.js scripts without actually writing Typescript

typescript Nov 8, 2024

Typescript is without a doubt my favorite way to write Javascript in 2024. However, there are times when I don't want to actually have to run tsc or tsc --watch to do basic scripting tasks, but I also really want to have type safe Javascript, AND be able to use ES Modules.

A few innovations both on the Typescript side, and the node.js side in the last few years have made it way easier to have your cake and eat it too when it comes to writing typesafe Javascript AND getting to use modern ES modules instead of commonjs!

Use jsdoc for the types

JSDoc has been around for quite a while now. I remember using it years ago to generate API docs when I built Backbone.js apps.

It's very similar in concept to what you'd use in C# apps as well, where you describe what a function or variable does by simply writing a code comment above it.

/**
 * Debounces a given function
 * @param {function} func function to debounce
 * @param {number} time how long to debounce calls to func for (in milliseconds)
 * @returns { function } debounced function
 */
export function debounce(func, time) {
  // ...
}

You can use the @type to simply set a given JS var as a type.

  export function debounce(func, time) {
    /** @type { NodeJS.Timeout | null } */
    let timeout = null;

    // ... 
  }

You can even define custom types using @typedef.

/**
 * @typedef { Object } NpmrcOrg
 * @property { string } feed
 * @property { string } organization
 * @property { string } [pat]
 */

/** @type { Array<NpmrcOrg> } */
const defaultFeeds = [
  {
    organization: "foo",
    feed: "https://foo.dev.azure.com/foo/bar/_packaging/foo/npm/registry/",
  },
];

Using the square brackets marks a field as optional, both in @property and in @param like [pat] in the above example.

Here we're declaring a type called NpmrcOrg and then using it immediately below.

You can also reference types from other files, and even from NPM packages using @import

/**
 * Create the user npmrc given an array of registry urls and a token
 * @param {object} options
 * @param {Array<import("../../utils/pat.js").NpmrcOrg> } options.feeds
 * @param {string} options.existingNpmrc
 * @returns {string}
 */
const createUserNpmrc = ({ feeds, existingNpmrc }) => {

Or referencing a node module...

import https from "https";
/**
 *
 * @param {import("http").RequestOptions} options
 * @returns
 */
export const makeRequest = async (options) => {

Here are the docs on all the possible things you can do with JSDoc.

Documentation - JSDoc Reference
What JSDoc does TypeScript-powered JavaScript support?

Configure Typescript to check Javascript and allow ES Modules

There are a few important flags to have in your tsconfig.json and package.json to make this all work.

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["dom", "ESNext"],
    "module": "ESNext",
    "allowJs": true,
    "checkJs": true,
    "rootDir": "./src",
    "outDir": "./lib",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}

The checkJs and allowJs will instruct both the Typescript compiler, and VSCode to do typechecking on your JS and thus parse all the JSDoc comments into Typescript.

The module being set to "ESNext" is what tells TS that you're going to be using ESModules.

If you hate the import * as stuff you can set "allowSyntheticDefaultImports" and "esModulesInterop".

Running tsc against that tsconfig will produce JS files in ./lib along with the .d.ts files created from parsing your JS files, and the JSDoc comments.

Lastly, in your package.json if you want to, you can set "module": true that will tell node when you run it that you're using ES Modules, and as of a few node versions ago, the import and export of modules will just work by running node path/to/your/script.js

Read more about node.js's ES Modules and about how Typescript handles ES Modules as well.

This works so well in VSCode

The best thing ever is seeing the magic of all this working in VSCode. 😍

You get full intellisense and code completion like you would using native .ts files.

showing the param flyover
Showing the full debounce function (thanks to my co-worker Stu for the nice debounce😉)

You get the goodness of Typescript without needing to do a single yarn build or npm run build or anything, because, guess what, you're using native Javascript!

Conclusion

Lots of open source tools have moved towards this model, including, but not limited to webpack, and svelte.

There are definitely still advantages to using full on .ts extensions, and using the tsc compiler, or swc / babel etc to do the transpiling work, but it's nice to know that you don't have to go for a full typescript build for every part of your code base.

This type of programming works great for scripting where you just want to automate some tasks, do system level work, write a CLI, etc. The feedback loop is very fast because there's no compile step at all.

Go forth and write more native Javascript with type checking!

Tags