Node runs TypeScript now: what strips, what refuses
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.
| Construct | Node 22 | Node 24 | Node 26 | What happens |
|---|---|---|---|---|
| Type annotations | works | works | works | erased |
interface declaration | works | works | works | erased |
| Type alias with generics | works | works | works | erased |
satisfies | works | works | works | erased |
Non-null assertion x! | works | works | works | erased |
as const | works | works | works | erased |
import type | works | works | works | erased |
abstract class | works | works | works | the class is real, the abstract-ness is not enforced |
Generic arrow <T,>() => | works | works | works | needs the trailing comma in a .ts file |
import './dep.ts' | works | works | works | the extension is required |
enum | refused | refused | refused | ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX |
const enum | refused | refused | refused | same error |
namespace | refused | refused | refused | same error |
| Parameter properties | refused | refused | refused | same error |
| Decorators | refused | refused | refused | SyntaxError: Invalid or unexpected token |
import './dep' (no extension) | refused | refused | refused | ERR_MODULE_NOT_FOUND |
| Option | Node 22 | Node 24 | Node 26 | Why | Pick it when |
|---|---|---|---|---|---|
| Types, interfaces, generics, satisfiesdefault | works | works | works | pure annotation — deleting it leaves valid JavaScript | Everything: this is the subset type stripping was designed for |
| enum and const enum | refused | refused | refused | an enum emits a runtime object; stripping cannot invent it | Never in a file you want Node to run directly — use a const object with as const |
| namespace | refused | refused | refused | also emits runtime code, and modules replaced it years ago | Legacy declaration files only |
| Parameter properties | refused | refused | refused | constructor(public id) writes an assignment that is not in the source | Codebases that stay on a compiler — NestJS depends on this shape |
| Decorators | refused | refused | refused | still a proposal in JavaScript; the runtime parser rejects the token | Same: compiler required |
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.
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
}
}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.mjsIt 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. export const enum Level { Info, Warn }
2. import { db } from './db' — in a .ts file run directly by Node
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