•5 min read

Node Type Stripping: What Breaks When You Delete ts-node

Two weeks ago I deleted ts-node from a service I maintain, changed the dev script to node --watch src/server.ts, and pushed it. Cold start got faster, one dependency disappeared, and I stopped worrying about my runner disagreeing with my compiler over module resolution. Then a teammate added an enum to a shared module and CI went red with an error I had never seen before.

That is the shape of adopting Node's built-in type stripping. The happy path is good. The failure modes are narrower than the guides suggest, and they cluster where your type checker is happy to sign off.

What Node actually does with a .ts file

It does not compile anything. Type stripping replaces annotations with whitespace and hands the rest to V8, and the rules below follow from that.

Because types become whitespace rather than code, offsets never move. I threw an error on line 6 of a .ts file and the trace says line 6. Node generates no source maps. If a guide told you otherwise, that was the old transform mode.

Flaking orange paint peeling away from a wall to reveal the paler layer underneath

Two more consequences. Nothing is type-checked at runtime, so const age: number = "forty" runs and prints forty. And tsconfig.json is never read, which is why paths and downleveling do nothing.

The four things that stop the process

Anything that has to become real JavaScript cannot be erased. Node names each one:

$ node roles.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

$ node flags.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript namespace declaration is not supported in strip-only mode

$ node counter.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode

$ node legacy.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript import equals declaration is not supported in strip-only mode

That covers enums, a namespace exporting a runtime value, constructor parameter properties, and import x = require(). The parameter property error underlines only private count: number. A namespace exporting types and nothing else is fine, which trips people up: from the outside it looks identical.

Decorators are separate. They are still a Stage 3 proposal, so Node does not transform them and you get a parser error instead of a message naming the problem. On NestJS or TypeORM, that is a stopping point.

Enums were the only construct I had to migrate, and the replacement reads better:

// before
enum Role { Admin = 'admin', Editor = 'editor' }

// after
const ROLES = ['admin', 'editor'] as const
type Role = (typeof ROLES)[number]

It is erasable, survives a round trip through JSON, and does not build the odd runtime object enums produce.

The import that type-checks and then crashes

This is the one that cost me ten minutes. Nothing looks wrong until it runs. A shared module exported an interface, and a file imported it the normal way:

import { User } from './types.ts'
const u: User = { name: 'x' }

tsc is content with that. When a name is only a type, TypeScript drops the import as it emits. Node reads it literally, asks ./types.ts for a runtime export called User, and stops with "The requested module './types.ts' does not provide an export named 'User'".

Marking it as a type fixes it, either as import type { User } or inline as import { fn, type FnParams }. verbatimModuleSyntax turns this runtime surprise into a build failure, and it is the first thing to switch on before you delete anything.

Extensions, aliases, and node_modules

Node treats a .ts file exactly like a .js file, extensions and all, so specifiers need the real name. import './value.ts', never import './value', and the same goes for require(). Miss it and you get ERR_MODULE_NOT_FOUND. rewriteRelativeImportExtensions lets tsc accept those specifiers and still emit working JavaScript.

Path aliases fail for a related reason. A file importing @/lib/db.ts dies with Cannot find package '@/lib', and the word "package" is the tell: Node sees a bare specifier and looks in node_modules because it never read your tsconfig. Subpath imports are the replacement, and they must start with a hash:

{
  "type": "module",
  "imports": {
    "#lib/*": "./src/lib/*"
  }
}

One more rule catches package authors. Node refuses to strip types under any node_modules path, so a dependency shipping raw .ts fails with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Published packages still ship JavaScript, so your workspace symlinks deserve a look.

The flag that moved, twice

The escape hatch for enums and parameter properties, --experimental-transform-types, was removed in the same Node 26 release line that turned on node:ffi. A start script that still passes it leans on something gone, and anyone who genuinely needs a full transform is pointed at a package like tsx.

The disable switch was renamed in 24.12 and 25.2, from --no-experimental-strip-types to --no-strip-types. On the 22.x line the new spelling is rejected as a bad option, which is how I found out. To check a runtime's mode, process.features.typescript returns "strip", or false when stripping is off.

What I settled on

This is the config I run, and it turns every mistake above into a compile error instead of a runtime surprise:

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

erasableSyntaxOnly is the load-bearing line. It rejects an enum at check time, so the constraint is enforced by tooling instead of by whoever reviews the pull request.

Type checking did not go away, it moved. tsc --noEmit stopped being something the runtime did for me and became an explicit CI step, which is where it belonged. Same argument as the TypeScript 7 rewrite: the checker is still the checker.

I also kept tsx for the one decorator-heavy corner that genuinely needs a transform. Deleting a dependency feels great; installing it again under a different name does not.

The enum pattern and that tsconfig block live in my Snippet Ark library, because I have typed it out on three projects and would rather not do a fourth from memory. If you keep your own list of configs that finally worked, you know the feeling.