Node runs TypeScript now: what strips, what refuses

Sanjeev SharmaSanjeev Sharma
12 min read

Advertisement

Node runs .ts files. That sentence is doing a lot of work, because what it runs is TypeScript with the types removed, not TypeScript compiled — and the difference is a list of constructs your codebase may already contain.

Sixteen of them were written to disk and executed under Node 22, 24 and 26. This is what came back.

The short answer

Eleven of sixteen constructs run unchanged: annotations, interfaces, generics, satisfies, non-null assertions, as const, type-only imports, abstract classes, generic arrows and .ts import paths. Five are refused with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX — enum, const enum, namespace, parameter properties — plus decorators, which fail as a syntax error. Identical on 22, 24 and 26.

The full matrix

Every case is the smallest file that exercises exactly one construct, executed with node file.ts inside the official image for each version.

ConstructNode 22Node 24Node 26What happens
Type annotationsworksworksworkserased
interface declarationworksworksworkserased
Type alias with genericsworksworksworkserased
satisfiesworksworksworkserased
Non-null assertion x!worksworksworkserased
as constworksworksworkserased
import typeworksworksworkserased
abstract classworksworksworksthe class is real, the abstract-ness is not enforced
Generic arrow <T,>() =>worksworksworksneeds the trailing comma in a .ts file
import './dep.ts'worksworksworksthe extension is required
enumrefusedrefusedrefusedERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
const enumrefusedrefusedrefusedsame error
namespacerefusedrefusedrefusedsame error
Parameter propertiesrefusedrefusedrefusedsame error
DecoratorsrefusedrefusedrefusedSyntaxError: Invalid or unexpected token
import './dep' (no extension)refusedrefusedrefusedERR_MODULE_NOT_FOUND
OptionNode 22Node 24Node 26WhyPick it when
Types, interfaces, generics, satisfiesdefaultworksworksworkspure annotation — deleting it leaves valid JavaScriptEverything: this is the subset type stripping was designed for
enum and const enumrefusedrefusedrefusedan enum emits a runtime object; stripping cannot invent itNever in a file you want Node to run directly — use a const object with as const
namespacerefusedrefusedrefusedalso emits runtime code, and modules replaced it years agoLegacy declaration files only
Parameter propertiesrefusedrefusedrefusedconstructor(public id) writes an assignment that is not in the sourceCodebases that stay on a compiler — NestJS depends on this shape
Decoratorsrefusedrefusedrefusedstill a proposal in JavaScript; the runtime parser rejects the tokenSame: compiler required
The pattern is one rule: if erasing the syntax would change what the program does at runtime, the runtime refuses it rather than guessing.

Why are exactly those five refused?

Because they are not type syntax. They are code that happens to be written in a type-ish way, and stripping them would silently change behaviour.

What each refused construct would have to emit
1 / 5

Type stripping replaces type syntax with whitespace, which is why source positions survive and no source map is needed. It also explains the refusals: nothing can be replaced with whitespace if the program needed it to be there.

Decorators are the one refusal that is not Node's decision alone: the TC39 decorators proposal is still making its way through the committee, and its semantics differ from the legacy TypeScript implementation most codebases use. Until that lands, there is no single meaning for the runtime to pick.

The type-stripping documentation states the rule directly: syntax that requires code generation is unsupported, and the runtime raises ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX rather than transform it. The TypeScript side of the story is erasableSyntaxOnly, a compiler flag that fails the build on exactly the constructs Node refuses — which is how you stop them reaching a file you meant to run directly.

What this means for a real codebase

Two things, and the second one surprises people.

The first: if you want node src/server.ts to work, turn on erasableSyntaxOnly in tsconfig.json today. It converts a class of runtime surprises into compile errors, and the migration is usually one afternoon — enums become const objects, parameter properties become explicit assignments.

The second: file extensions. import { v } from './dep' fails with ERR_MODULE_NOT_FOUND, because Node resolves modules by specifier and does not guess extensions. ./dep.ts works. This is the change that breaks the most existing files, and it has nothing to do with types.

// what a stripped file looks like after the migration
import { logger } from './logger.ts'          // extension, not optional
 
export const Colour = { Red: 0, Blue: 1 } as const   // was: enum Colour
export type Colour = (typeof Colour)[keyof typeof Colour]
 
export class Session {
  readonly id: string                          // was: constructor(public id: string)
  constructor(id: string) {
    this.id = id
  }
}
accepted — the type characters become spacesconst n: number= 41 + 1→ const n = 41 + 1refused — the syntax was load-bearing at runtimeenum Colour { Red, Blue }→ nothing to strip to; the object is neededconstructor(public id: string) {}→ the assignment is not in the source@log method() {}→ not JavaScript yet, parser refuses the token

One rule decides every row of the matrix: can the syntax be replaced with whitespace without changing what the program does?

Does this replace your build step?

For a service with no bundler, no path aliases and no decorators — yes, and the payoff is that node --watch src/server.ts restarts in the time it takes to read the file rather than the time it takes to type-check it.

For everything else, no, and the reason is that stripping does not type-check. Nothing in the runtime notices that const n: number = 'x' is wrong; it deletes the annotation and runs. Type checking stays a separate step you run in CI and in your editor, which is the same division the TypeScript backend series describes, and it is the one people get wrong when they hear "Node runs TypeScript".

What the migration costs you

Mechanical edits (codemod territory)

1,102 edits

180 files × 6 imports + 22 constructors

Edits needing a decision

14 files

enum → const object, namespace → module: a human picks the shape

Rough effort at 40 mechanical edits a minute

84 minutes

mechanical ÷ 40 per minute, plus 14 × 4 min of judgement

The extension rewrite dominates and is the part a codemod does for you. The enum and namespace files are the ones worth reading, because a const object is not identical to an enum — there is no reverse mapping from value back to name.

How to check your own code in one command

Point the probe at your own constructs rather than trusting this table:

docker run --rm -v "$PWD":/w -w /w node:24-alpine \
  node tools/bench/strip-types/probe.mjs

It writes each construct to a temp file, runs it, and reports the runtime's own error. Add your own cases to the CASES object — the value of the script is that it answers with the runtime rather than with documentation, and runtimes change.

Would Node run this file?

3 questions — answers explained as you go.

  1. 1. export const enum Level { Info, Warn }

  2. 2. import { db } from './db' — in a .ts file run directly by Node

  3. 3. const port: number = 'eighty' — what does Node do?

Frequently Asked Questions

Can Node run TypeScript files directly?

Yes, by stripping type syntax before execution. Eleven of the sixteen constructs tested here run unchanged on Node 22, 24 and 26. Five are refused: enum, const enum, namespace, parameter properties and decorators, because each one needs code the runtime would have to generate.

Why does Node refuse enums?

An enum is a runtime object with a reverse mapping from value to name, not a type. Stripping the declaration would leave every use of the identifier undefined, so the runtime raises ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX instead of guessing.

Does Node type-check my TypeScript?

No. It deletes type syntax and runs what is left, so const port: number = "eighty" executes happily. Keep tsc --noEmit in CI and rely on the editor for feedback while writing.

What is erasableSyntaxOnly?

A TypeScript compiler option that makes the compiler reject exactly the constructs Node cannot strip. Turning it on converts a class of runtime failures into build failures, which is the right place for them.

Do I still need a bundler or ts-node?

Not for a plain service with relative imports and no decorators — node --watch src/server.ts covers it. You still need a compiler for path aliases, decorators, parameter properties, and any output that has to run somewhere without Node.

What to take from the table

The five refusals are a coherent set rather than an arbitrary one, and knowing the rule means you never have to check the table again: anything that would still be doing work after the types are gone is not type syntax, and the runtime will tell you so by name.

If you are starting a service today, turn on erasableSyntaxOnly, write extensions on relative imports, and keep the compiler for checking rather than for running. The rest of the setup — tsconfig, module resolution, the build-versus-check split — is in the TypeScript backend setup post, and the runtime side of the same story is in Node 22 vs 24 vs 26.

For the parts of a service that this does not touch: error handling still needs deliberate design, async logging still belongs off the hot path, request context costs about 250 nanoseconds, and a regex with nested quantifiers will still stop the process regardless of which language you wrote it in.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading