Node’s new built-in support for TypeScript

[2025-01-08] dev, javascript, typescript
(Ad, please don’t block)

Starting with v23.6.0, Node.js supports TypeScript without any flags. This blog post explains how it works and what to look out for.

A first look at the new feature  

Consider the following file:

// demo.mts
function main(message: string): void {
  console.log('Message: ' + message);
}
main('Hello!');

We can now run it like this:

node demo.mts

For now, we get the following warning:

ExperimentalWarning: Type Stripping is an experimental feature and
might change at any time

We can switch off that warning:

node --disable-warning=ExperimentalWarning demo.mts

Tips for using --disable-warning:

Filename extensions  

  • .ts files work like .js files in that they can be either ESM or CommonJS.
    • This is a good choice for files in projects – whose package.json usually contains "type": "module".
  • .mts files are always ESM.
    • Use this filename extension for standalone files.
  • .cts files are always CommonJS.
  • .tsx files are not supported.

How is Node.js TypeScript different from normal TypeScript?  

Current TypeScript support in Node.js is done via type stripping: All Node.js does is remove all syntax related to types. It never transpiles anything. Let’s explore how that changes how we write TypeScript.

No support for non-JavaScript language features  

These include:

No support for future JavaScript that is compiled to current JavaScript  

TypeScript supports some upcoming JavaScript features and transpiles them so that they run on current JavaScript engines. One such feature is decorators. Those will be supported by Node.js in TypeScript when they are supported in JavaScript.

Local imports must refer to TypeScript files  

In traditional TypeScript, we refer to the transpiled versions of modules:

import { myFunction } from './my-module.js';

Why is that? Traditionally, the TypeScript compiler never touched module specifiers such as './my-module.js'. Therefore, we had to use module specifiers that made sense in the transpiled output.

Given that Node.js uses the filename extension to determine the type of a module, this approach had to change. We now have to write:

import { myFunction } from './my-module.ts';

I personally prefer this approach even for code that is not meant to run on Node.js. The section on tsconfig.json explains how to use it in that case.

Types must be imported via type imports  

If we want to import types, we have to use type imports – otherwise type stripping won’t remove them.

// Type import
import type { Cat, Dog } from './animal.ts';

// Inline type import
import { createCatName, type Cat, type Dog } from './animal.ts';

tsconfig.json  

Node’s type stripping ignores tsconfig.json but if we want to type-check during development, we need one. This is a minimal setup recommended by the Node.js documentation:

{
  "compilerOptions": {
     "target": "esnext",
     "module": "nodenext",
     "allowImportingTsExtensions": true,
     "rewriteRelativeImportExtensions": true,
     "verbatimModuleSyntax": true
  }
}

For larger projects, we probably want to run tsc and use the compilerOption called noEmit.

Let’s take a closer look at the last three options:

  1. allowImportingTsExtensions [supported since TypeScript 5.0] lets us import TypeScript files (extension .ts etc.) where we traditionally had to import their transpiled versions (extension .js etc.).

  2. rewriteRelativeImportExtensions [supported since TypeScript 5.7] transpiles relative imports from TypeScript files (extension .ts etc.) to relative imports from JavaScript files (extension .js etc.).

  3. verbatimModuleSyntax [supported since TypeScript 5.0] warns us if we don’t use the type keyword when importing a type.

Option #1 and #2 enable us to use Node.js TypeScript code in other contexts.

--input-type  

--input-type tells Node.js how to interpret code when it doesn’t come from a file (where the filename extension contains that information) – i.e., when it comes from stdin or --eval. This flag now supports the following values:

  • module
  • commonjs
  • module-typescript
  • commonjs-typescript

Type stripping and source maps  

ts-blank-space by Ashley Claymore for Bloomberg pioneered a clever approach for type stripping: If, instead of simply removing all text related to types, we “overwrite” it with spaces then source code positions in the output don’t change and stack traces etc. remain correct. There is no need for source maps.

For example - input (TypeScript):

function describeColor(color: Color): string {
  return `Color named “${color.colorName}”`;
}
type Color = { colorName: string };
describeColor({ colorName: 'green' });

Output (JavaScript):

function describeColor(color       )         {
  return `Color named “${color.colorName}”`;
}

describeColor({ colorName: 'green' });

Note the empty line between the declaration of describeColor() and its invocation.

Node.js type stripping uses the same approach and therefore does not generate source maps.

What’s next?  

TypeScript 5.8 can warn you about constructs not supported by type stripping  

Quoting Jake Bailey (a member of the TypeScript team at Microsoft):

A flag which bans TS features with runtime emit (enums, namespaces, experimental decorators, etc) will come in 5.8 to help people executing TS code via Node.js (or who want to avoid using those features for “reasons”).

--experimental-transform-types  

The work-in-progress feature --experimental-transform-types will actually transpile TypeScript and therefore support more features. It will generate source maps and enable source maps by default.

Further reading