Dev & EngARTICLE

Node.js runs TypeScript without a build step: what native type-stripping unlocks and what still breaks in the backend

Since 2024, Node.js has been able to run .ts files by stripping types on the fly, without ts-node, tsx, or swc. But enums, namespaces with code, and decorators remain out of reach.

Since 2024, Node.js has been able to run .ts files by stripping types on the fly, without ts-node, tsx, or swc. But enums, namespaces with code, and decorators remain out of reach.

Node.js's official documentation, on the Modules: TypeScript page, describes a feature that has already changed the workflow for those who maintain backend services in production: native type stripping. It isn't new. The experimental flag --experimental-transform-types appeared in v22.7.0, the feature became enabled by default in v23.6.0 (and in v22.18.0, on the LTS branch), stopped emitting the experimental warning in v24.3.0/v22.18.0, became stable in v25.2.0/v24.12.0, and, in v26.0.0, the experimental flag was removed for good.

What Node actually does with a .ts file

Type stripping is not compilation. Node reads the .ts file, locates the type syntax (annotations, interfaces, as Tipo) and replaces each stretch with whitespace, preserving line and column positions. There's no type checking, no new code generation, and consequently no need for source maps: the stack trace of an error in production already points to the right line in the original .ts file, because the spacing hasn't changed.

This explains the feature's central rule: it only works with erasable syntax - the kind that can become whitespace without changing the program's behavior. Pure typing meets this requirement. Any construct that needs to become new JavaScript code doesn't.

The shortest path: removing tsx or ts-node from the start script

For a typical HTTP service, without enum or decorator, the path looks like this:

ts
// server.ts
import type { IncomingMessage, ServerResponse } from 'node:http';
import { createServer } from 'node:http';

function handler(req: IncomingMessage, res: ServerResponse): void {
 res.writeHead(200, { 'Content-Type': 'application/json' });
 res.end(JSON.stringify({ ok: true }));
}

createServer(handler).listen(3000);

With "type": "module" in package.json, running it is just node server.ts, without installing anything extra. The detail that usually trips up those coming from ts-node is the mandatory extension: import './util.ts' works, import './util' doesn't. The same rule applies to require('./file.ts') in CommonJS projects (.cts files).

The documentation recommends TypeScript 5.8 or newer and a tsconfig.json with erasableSyntaxOnly: true, verbatimModuleSyntax: true, module: nodenext, and rewriteRelativeImportExtensions: true - but that's only for the editor and for tsc used as a parallel type checker, never at runtime. Node ignores tsconfig.json entirely.

What still requires keeping a transpiler in CI

The documentation itself lists the constructs that generate ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, because all of them require generating new JavaScript, not just erasing an annotation:

  • Enums (enum Cor { Vermelho, Verde }) - become an object at runtime, not pure type.
  • Namespaces with runtime code - a namespace A { export let x = 1 } breaks; a namespace TypeOnly { export type A = string } works, because nothing is left at runtime.
  • Parameter properties (constructor(private nome: string) {}) - the syntactic sugar that creates the class field from the constructor parameter.
  • Import aliases in the style of import A = require('mod').
  • Decorators - still a Stage 3 proposal at TC39; Node doesn't polyfill them and treats them as a parser error until they become standard JavaScript.

Anyone using NestJS, TypeORM with decorators, or any framework built on @Injectable()/@Entity() has no way to run directly with type stripping today. The same goes for legacy codebases full of enum - replacing it with as const and a union type solves it, but that's refactoring, not configuration.

Type imports require the type keyword, no exceptions

Since stripping is blind to meaning - it only looks at syntax -, the difference between importing a type and importing a value needs to be explicit in the code:

ts
import type { Usuario } from './modelos.ts'; // ok: becomes whitespace
import { Usuario } from './modelos.ts'; // runtime error if Usuario is only a type

Without the explicit type, Node treats the import as a value and the module breaks when trying to resolve something that doesn't exist in the compiled file. The verbatimModuleSyntax option in tsconfig.json helps tsc enforce this discipline in the editor, before it reaches runtime.

tsconfig's paths doesn't exist for Node

Anyone relying on path aliases (@app/* pointing to src/app/*) via paths in tsconfig.json loses that feature: Node doesn't read the file and throws an error when it hits the import. The official alternative is package.json subpath imports (the imports field), with the limitation that the key must start with # - a mechanism that's more limited and less familiar than paths, but native and transpiler-free.

Third-party packages remain out of reach

To discourage libraries published in pure TypeScript, Node refuses to apply type stripping to .ts files inside any folder under node_modules. This means the feature solves your application's code, not the dependency chain - anyone publishing a package still needs to compile to JavaScript before pushing it to npm, as has always been the case.

Where this effectively replaces ts-node, tsx, and swc-node

ScenarioNeeds a transpiler?
Simple CLI script, types and interfaces onlyNo
HTTP service without enum/decorator, disciplined import typeNo
Project with NestJS, TypeORM, or any @Decorator()Yes
Codebase with enum scattered aroundYes (or refactor to as const)
Import alias via tsconfig pathsYes, or migrate to subpath imports with #
.tsx file (JSX + TypeScript)Yes - .tsx is not supported by type stripping

In summary: for pure backend work - no JSX, no decorator, no enum -, you can remove tsx or ts-node from the start script and from the Dockerfile, cutting out an entire build dependency. For everything else, including any front-end in .tsx, the transpiler stays in the picture, and the sanest strategy is usually to migrate the erasable parts first and keep the rest in the build flow that already exists.

It's worth remembering that support covers --eval and STDIN input, but doesn't cover REPL, --check, or node inspect - a detail that trips up anyone trying to debug a piece of TypeScript directly in the interactive terminal and gets a syntax error without understanding why.

Source: Node.js's official documentation, Modules: TypeScript.

Translated from the Brazilian Portuguese original · Read the original