[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-node-type-stripping-without-ts-node":3},{"content":4,"lastModified":5},"\u003Cp>Two weeks ago I deleted \u003Ccode>ts-node\u003C\u002Fcode> from a service I maintain, changed the dev script to \u003Ccode>node --watch src\u002Fserver.ts\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>What Node actually does with a .ts file\u003C\u002Fh2>\n\n\u003Cp>It does not compile anything. Type stripping replaces annotations with whitespace and hands the rest to V8, and the rules below follow from that.\u003C\u002Fp>\n\n\u003Cp>Because types become whitespace rather than code, offsets never move. I threw an error on line 6 of a \u003Ccode>.ts\u003C\u002Fcode> file and the trace says line 6. Node generates no source maps. If a guide told you otherwise, that was the old transform mode.\u003C\u002Fp>\n\n\u003Cfigure>\n  \u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F36063471\u002Fpexels-photo-36063471.jpeg?auto=compress&cs=tinysrgb&w=1200\" alt=\"Flaking orange paint peeling away from a wall to reveal the paler layer underneath\" loading=\"lazy\" \u002F>\n\u003C\u002Ffigure>\n\n\u003Cp>Two more consequences. Nothing is type-checked at runtime, so \u003Ccode>const age: number = \"forty\"\u003C\u002Fcode> runs and prints forty. And \u003Ccode>tsconfig.json\u003C\u002Fcode> is never read, which is why \u003Ccode>paths\u003C\u002Fcode> and downleveling do nothing.\u003C\u002Fp>\n\n\u003Ch2>The four things that stop the process\u003C\u002Fh2>\n\n\u003Cp>Anything that has to become real JavaScript cannot be erased. Node names each one:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">$ node roles.ts\nSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode\n\n$ node flags.ts\nSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript namespace declaration is not supported in strip-only mode\n\n$ node counter.ts\nSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode\n\n$ node legacy.ts\nSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript import equals declaration is not supported in strip-only mode\n\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>That covers enums, a namespace exporting a runtime value, constructor parameter properties, and \u003Ccode>import x = require()\u003C\u002Fcode>. The parameter property error underlines only \u003Ccode>private count: number\u003C\u002Fcode>. A namespace exporting types and nothing else is fine, which trips people up: from the outside it looks identical.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>Enums were the only construct I had to migrate, and the replacement reads better:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F before\nenum Role { Admin = 'admin', Editor = 'editor' }\n\n\u002F\u002F after\nconst ROLES = ['admin', 'editor'] as const\ntype Role = (typeof ROLES)[number]\n\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>It is erasable, survives a round trip through JSON, and does not build the odd runtime object enums produce.\u003C\u002Fp>\n\n\u003Ch2>The import that type-checks and then crashes\u003C\u002Fh2>\n\n\u003Cp>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:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-typescript\">import { User } from '.\u002Ftypes.ts'\nconst u: User = { name: 'x' }\n\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>tsc\u003C\u002Fcode> is content with that. When a name is only a type, TypeScript drops the import as it emits. Node reads it literally, asks \u003Ccode>.\u002Ftypes.ts\u003C\u002Fcode> for a runtime export called \u003Ccode>User\u003C\u002Fcode>, and stops with \"The requested module '.\u002Ftypes.ts' does not provide an export named 'User'\".\u003C\u002Fp>\n\n\u003Cp>Marking it as a type fixes it, either as \u003Ccode>import type { User }\u003C\u002Fcode> or inline as \u003Ccode>import { fn, type FnParams }\u003C\u002Fcode>. \u003Ccode>verbatimModuleSyntax\u003C\u002Fcode> turns this runtime surprise into a build failure, and it is the first thing to switch on before you delete anything.\u003C\u002Fp>\n\n\u003Ch2>Extensions, aliases, and node_modules\u003C\u002Fh2>\n\n\u003Cp>Node treats a \u003Ccode>.ts\u003C\u002Fcode> file exactly like a \u003Ccode>.js\u003C\u002Fcode> file, extensions and all, so specifiers need the real name. \u003Ccode>import '.\u002Fvalue.ts'\u003C\u002Fcode>, never \u003Ccode>import '.\u002Fvalue'\u003C\u002Fcode>, and the same goes for \u003Ccode>require()\u003C\u002Fcode>. Miss it and you get \u003Ccode>ERR_MODULE_NOT_FOUND\u003C\u002Fcode>. \u003Ccode>rewriteRelativeImportExtensions\u003C\u002Fcode> lets \u003Ccode>tsc\u003C\u002Fcode> accept those specifiers and still emit working JavaScript.\u003C\u002Fp>\n\n\u003Cp>Path aliases fail for a related reason. A file importing \u003Ccode>@\u002Flib\u002Fdb.ts\u003C\u002Fcode> dies with \u003Ccode>Cannot find package '@\u002Flib'\u003C\u002Fcode>, 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:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"type\": \"module\",\n  \"imports\": {\n    \"#lib\u002F*\": \".\u002Fsrc\u002Flib\u002F*\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>One more rule catches package authors. Node refuses to strip types under any \u003Ccode>node_modules\u003C\u002Fcode> path, so a dependency shipping raw \u003Ccode>.ts\u003C\u002Fcode> fails with \u003Ccode>ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING\u003C\u002Fcode>. Published packages still ship JavaScript, so your workspace symlinks deserve a look.\u003C\u002Fp>\n\n\u003Ch2>The flag that moved, twice\u003C\u002Fh2>\n\n\u003Cp>The escape hatch for enums and parameter properties, \u003Ccode>--experimental-transform-types\u003C\u002Fcode>, was removed in the same Node 26 release line that \u003Ca href=\"\u002Fposts\u002Fnode-ffi-call-c-libraries-without-addon\u002F\">turned on node:ffi\u003C\u002Fa>. 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 \u003Ccode>tsx\u003C\u002Fcode>.\u003C\u002Fp>\n\n\u003Cp>The disable switch was renamed in 24.12 and 25.2, from \u003Ccode>--no-experimental-strip-types\u003C\u002Fcode> to \u003Ccode>--no-strip-types\u003C\u002Fcode>. 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, \u003Ccode>process.features.typescript\u003C\u002Fcode> returns \u003Ccode>\"strip\"\u003C\u002Fcode>, or \u003Ccode>false\u003C\u002Fcode> when stripping is off.\u003C\u002Fp>\n\n\u003Ch2>What I settled on\u003C\u002Fh2>\n\n\u003Cp>This is the config I run, and it turns every mistake above into a compile error instead of a runtime surprise:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"target\": \"esnext\",\n    \"module\": \"nodenext\",\n    \"rewriteRelativeImportExtensions\": true,\n    \"erasableSyntaxOnly\": true,\n    \"verbatimModuleSyntax\": true\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>erasableSyntaxOnly\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>Type checking did not go away, it moved. \u003Ccode>tsc --noEmit\u003C\u002Fcode> stopped being something the runtime did for me and became an explicit CI step, which is where it belonged. Same argument as \u003Ca href=\"\u002Fposts\u002Ftypescript-7-native-compiler-what-changes\u002F\">the TypeScript 7 rewrite\u003C\u002Fa>: the checker is still the checker.\u003C\u002Fp>\n\n\u003Cp>I also kept \u003Ccode>tsx\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>The enum pattern and that tsconfig block live in my \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> 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.\u003C\u002Fp>\n","2026-10-03",1791168103939]