Build1 publisher3 min readPublished
Node 24 overwrites a 34-character type annotation with 34 spaces to keep columns exact
Running .ts files directly drops tsx and the dist folder, and because the stripper blanks annotations in place instead of deleting them, stack traces stay accurate without a source map. Node still never opens tsconfig.json.
The Engineer · Build desk

What happened
- On Node 24, `node server.ts` runs with no flag, no loader, no tsx and no dist folder: the runtime reads the TypeScript, throws the types away, and executes what is left.
- The flag most people still type, --experimental-strip-types, has been unnecessary since Node 23.6, and on Node 24 the output is byte-identical whether you pass it or not.
- Because annotations are replaced by an equal count of blanks, the example line measures 139 characters in both the file on disk and the file Node executed.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Type checking now happens only where someone runs tsc, so a clean `node --watch` session proves the file parsed and nothing more; the guarantee lives in the check script or in CI.
- capability For small services you can debug the executed file directly, because the column in the stack frame is the column in your editor and no source map has to be generated, shipped or kept in sync.
- constraint Compiler configuration stops influencing the run: path aliases have to be re-expressed in package.json, and anything that needs emitted JavaScript still needs a compiler in front of it.
- precedent Shipping .ts straight to production becomes an auditable deployment choice, since --no-strip-types makes the runtime reject .ts and exposes which artifact is actually running.
Count the characters on the line that failed. It reads `const describe = (invoice: Invoice, options: Readonly<Record<string, string>>): string => invoice.id.toUpperCase() + options.locale.trim();` [4], and the post reports it is exactly 139 characters in both the file on disk and the file Node executed [7]. `options.locale.trim()` starts at column 118, which puts the `t` of `trim` at column 133 [1]. Node's stack frame printed `cols.ts:3:133` [5].
Blanking is why. "annotations are overwritten in place with the same number of blanks, never removed," the post's author wrote [8]. `: Invoice` is nine characters and the executed copy has nine spaces in its place; `: Readonly<Record<string, string>>` is thirty-four, and so is the gap [6]. Those two annotations account for 43 of the line's 139 characters [2]. The caret lands under the right character with no source map involved [9].
Node bundles Amaro, a thin wrapper around swc's TypeScript stripper compiled to WebAssembly, which parses a `.ts`, `.mts` or `.cts` file, blanks out everything that exists only for the type checker, and hands the result to V8 [10]. Node never looks for `tsconfig.json`: not `paths`, not `experimentalDecorators`, not `target` [11]. It skips checking and resolution entirely [12]. Overwriting characters cannot generate code, so any TypeScript feature that has to emit JavaScript is out of scope by construction [13].
That is visible in the author's four-file service, which installs nothing at runtime and keeps three scripts: `dev` is `node --watch server.ts`, `test` is `node --test`, and `check` is `tsc --noEmit` [16]. The compiler still runs, only out of the check script now. With `tsconfig` ignored, aliasing moves into the `imports` field of `package.json`, mapping `#src/*` to `./src/*`, and the domain module imports `#src/money.ts` with the extension of the file that is actually on disk [16][17]. The author flags that specifier in passing: "Hold that thought; it comes back later as an error message." [17]
The report covers one four-module service and the two scripts that publish the author's blog [18]. For the result to transfer, three things have to be true of your code. Nothing depends on `target` downleveling or `experimentalDecorators` [11]. Every import specifier already names a file that exists on disk with its real extension [12]. And someone runs `tsc --noEmit` somewhere a failure blocks a merge [16]. The post says the approach "refuses five specific things, and one of them fails in a way that will cost you an afternoon if nobody tells you first" [18]; the available text sets out the three consequences above and breaks off inside `parseNewInvoice` [19].
`process.features.typescript` reports `"strip"` on the Node 24 default, `"transform"` under `--experimental-transform-types`, and `false` under `--no-strip-types` [14]. That last flag turns `.ts` back into an unknown extension, which is how the author proves a deployment is running compiled output instead of stripping on the fly [15]. `--experimental-strip-types` has been unnecessary since Node 23.6, and the author reported typing it anyway, "for a week out of muscle memory, like a password for an account that no longer exists" [2][3].
What to watch
- The four remaining refusals the post promises, including the one the author says costs an afternoon, are not in the available text.
- Whether TypeScript's own tooling accepts the `#src/money.ts` specifier the author says returns later as an error message.
- Whether --experimental-transform-types stays the supported escape hatch for features that require emitted JavaScript.