Node 24 Runs TypeScript With No Build Step. Here's What It Won't Do.
Node 24 Runs TypeScript With No Build Step. Here's What It Won't Do. node server.ts works. No flag, no loader, no tsx, no dist/ folder. Node 24 reads the TypeScript, throws the types away, and runs what is left. The f
Node 24 Runs TypeScript With No Build Step. Here's What It Won't Do.
node server.ts works. No flag, no loader, no tsx, no dist/ folder. Node 24 reads the TypeScript, throws the types away, and runs what is left.
The flag everybody still types β --experimental-strip-types β has been unnecessary since Node 23.6, and on 24 you can pass it or leave it off and get byte-identical output. I typed it for a week out of muscle memory, like a password for an account that no longer exists.
"Throws the types away" is not a metaphor, and Node shows you exactly what it means the first time something blows up. Here is a whole file β three lines of code, one of them carrying two long type annotations:
interface Invoice { id: string }
const describe = (invoice: Invoice, options: Readonly<Record<string, string>>): string => invoice.id.toUpperCase() + options.locale.trim();
console.log(describe({ id: "inv-1" }, {}));
And here is what Node printed when I ran it:
file:///C:/code/scratch/cols.ts:3
const describe = (invoice , options ) => invoice.id.toUpperCase() + options.locale.trim();
^
TypeError: Cannot read properties of undefined (reading 'trim')
at describe (file:///C:/code/scratch/cols.ts:3:133)
Compare line 3 in the two blocks. : Invoice is nine characters, and Node's copy has nine spaces where it was. : Readonly<Record<string, string>> is thirty-four, and so is the gap that replaced it. Both versions of that line are exactly 139 characters long.
That is the entire mechanism: annotations are overwritten in place with the same number of blanks, never removed. So column 133 in the file Node executed is column 133 in the file I wrote, the caret lands under the right character, and no source map was needed to put it there.
This post is what I learned taking that seriously β first on a four-module service, then on the two scripts that publish this blog. It works. It also refuses five specific things, and one of them fails in a way that will cost you an afternoon if nobody tells you first.
What is actually happening
Node bundles Amaro, a thin wrapper around swc's TypeScript stripper compiled to WebAssembly. When it loads a .ts, .mts or .cts file, it parses the source, blanks out everything that exists only for the type checker, and hands the result to V8.
Three consequences follow from that one sentence, and every gotcha in this article is one of them:
-
It does not read your
tsconfig.json. Notpaths, notexperimentalDecorators, nottarget. Node never looks for the file. - It does not type check. Nothing is validated. Nothing is even resolved.
- It cannot generate code. Blanking out characters is the entire mechanism, so any TypeScript feature that has to emit JavaScript is out of scope by construction.
You can ask the running process which mode it is in:
process.features.typescript
// "strip" -> the Node 24 default
// "transform" -> with --experimental-transform-types
// false -> with --no-strip-types
That last one is worth knowing: --no-strip-types turns .ts back into an unknown extension, which is how you prove a deployment is running compiled output instead of stripping on the fly.
The zero-build loop, on something real
Here is the shape I now use for small services: four files, no build tooling, nothing installed at runtime.
The package.json does two interesting things β it declares ESM, and it maps #src/* so imports do not turn into ../../.. chains:
{
"name": "invoice-api",
"private": true,
"type": "module",
"imports": { "#src/*": "./src/*" },
"scripts": {
"dev": "node --watch server.ts",
"test": "node --test",
"check": "tsc --noEmit"
}
}
src/money.ts is the boring module that exists so money is never a float:
export type Cents = number;
/** Accepts only a non-negative integer number of cents β no floats, no strings. */
export function parseCents(raw: unknown): Cents | null {
return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : null;
}
export function formatCents(cents: Cents): string {
return (cents / 100).toFixed(2);
}
src/invoices.ts holds the domain. Note the import specifier β #src/money.ts, with the extension of the file that is actually on disk. Hold that thought; it comes back later as an error message.
import { type Cents, parseCents } from "#src/money.ts";
export type InvoiceState = "draft" | "sent" | "paid";
export interface Invoice {
readonly id: string;
readonly customer: string;
readonly amountCents: Cents;
readonly state: InvoiceState;
}
export interface NewInvoice {
readonly customer: string;
readonly amountCents: Cents;
}
export function parseNewInvoice(body: unknown): NewInvoice | null {
if (typeof body !== "object" || body === null) return null;
const { customer, amountCents } = body as Record<string, unknown>;
const cents = parseCents(amountCents);
if (typeof customer !== "string" || customer.trim().length === 0) return null;
if (cents === null) return null;
return { customer: customer.trim(), amountCents: cents };
}
export class InvoiceStore {
readonly #byId = new Map<string, Invoice>();
#sequence = 0;
add(draft: NewInvoice): Invoice {
const invoice: Invoice = {
id: `INV-${(++this.#sequence).toString().padStart(4, "0")}`,
customer: draft.customer,
amountCents: draft.amountCents,
state: "draft",
};
this.#byId.set(invoice.id, invoice);
return invoice;
}
find(id: string): Invoice | undefined {
return this.#byId.get(id);
}
all(): readonly Invoice[] {
return [...this.#byId.values()];
}
}
server.ts uses node:http and no framework, with the two things a demo usually skips and production never forgives: a hard cap on the request body, and a shutdown that lets in-flight requests finish.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { InvoiceStore, parseNewInvoice, type Invoice } from "#src/invoices.ts";
import { formatCents } from "#src/money.ts";
const MAX_BODY_BYTES = 16 * 1024;
const store = new InvoiceStore();
function send(response: ServerResponse, status: number, payload: unknown): void {
const body = JSON.stringify(payload);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
response.end(body);
}
/** Reads the body with a hard cap, so one client cannot make the process grow forever. */
async function readJson(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw new RangeError("body too large");
chunks.push(chunk as Buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
}
function toResponseBody(invoice: Invoice): Record<string, unknown> {
return { ...invoice, amount: formatCents(invoice.amountCents) };
}
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
if (request.method === "GET" && url.pathname === "/invoices") {
send(response, 200, store.all().map(toResponseBody));
return;
}
if (request.method === "POST" && url.pathname === "/invoices") {
let body: unknown;
try {
body = await readJson(request);
} catch (error) {
const status = error instanceof RangeError ? 413 : 400;
send(response, status, { error: "unreadable body" });
return;
}
const draft = parseNewInvoice(body);
if (draft === null) {
send(response, 422, { error: "customer must be a non-empty string, amountCents a whole number" });
return;
}
send(response, 201, toResponseBody(store.add(draft)));
return;
}
send(response, 404, { error: "no such route" });
});
server.listen(Number(process.env["PORT"] ?? 3000), () => {
console.log(`invoice-api listening on ${JSON.stringify(server.address())}`);
});
// Stop accepting connections and let in-flight requests finish before exiting.
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
server.close(() => process.exit(0));
server.closeIdleConnections();
});
}
node --watch server.ts gives you the reload loop. And the part that surprised me most pleasantly: the built-in test runner needs no configuration at all to run TypeScript tests.
import { test } from "node:test";
import assert from "node:assert/strict";
import { InvoiceStore, parseNewInvoice, type Invoice } from "#src/invoices.ts";
test("rejects an amount that is not whole cents", () => {
assert.equal(parseNewInvoice({ customer: "Cafe Luna", amountCents: 1299.5 }), null);
});
test("rejects a blank customer", () => {
assert.equal(parseNewInvoice({ customer: " ", amountCents: 1299 }), null);
});
test("numbers invoices from one and starts them as drafts", () => {
const store = new InvoiceStore();
const draft = parseNewInvoice({ customer: "Cafe Luna", amountCents: 129900 });
assert.ok(draft);
const invoice: Invoice = store.add(draft);
assert.equal(invoice.id, "INV-0001");
assert.equal(invoice.state, "draft");
});
node --test finds *.test.ts, strips it, runs it. Three tests in 259 ms including process startup β no Jest config, no ts-jest, no transform block that nobody on the team understands.
It does not type check. At all.
This file runs:
const amountCents: number = "129900";
console.log(amountCents.toFixed(2));
TypeError: amountCents.toFixed is not a function
A runtime TypeError for a mistake the compiler would have caught while I was still typing it. Type stripping is not a TypeScript implementation; it is a way to ignore TypeScript efficiently. Checking is still your job β it just stops being on the critical path of running the code:
npx tsc --noEmit # 838 ms on this project, with TypeScript 7's native compiler
That split is the real win, and it is bigger than it looks. Checking happens in your editor as you type, and once more in CI. Running happens hundreds of times a day, and now it does not wait for a compiler.
Two tsconfig.json options make the checker enforce what Node can actually execute:
{
"compilerOptions": {
"module": "nodenext",
"target": "es2024",
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["server.ts", "src/**/*.ts"]
}
erasableSyntaxOnly rejects, at check time, every construct Node will refuse at run time β error TS1294 instead of a crash. verbatimModuleSyntax prevents the one failure in this article I genuinely lost time to. Turn both on before you migrate anything, not after.
The five things that break
1. Enums, namespaces and parameter properties
These are not annotations. They compile to runtime code, and blanking out characters cannot produce runtime code. All three fail the same way:
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript namespace declaration is not supported in strip-only mode
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode
That is a good error message: it names the construct and the mode. Parameter properties are the one that stings, because constructor(private readonly baseUrl: string) {} is the idiom half the NestJS ecosystem is built on.
--experimental-transform-types makes all three work, at a price: it is still experimental, it prints a warning on every run, and it no longer preserves positions, because now Node is generating code rather than blanking characters. My proof of that was accidental β a file with a decorator on line 6 reported the syntax error on line 6 in strip mode and on line 5 with transform enabled. Stack traces still come out right, since Node enables source maps for that mode; a syntax error happens before any map exists.
The alternative is to stop writing the three constructs. A union type replaces most enums and gives you better narrowing anyway:
export type InvoiceState = "draft" | "sent" | "paid";
2. Decorators, with no explanation whatsoever
Decorators are the exception to the helpful-error rule, for a precise reason: they are not TypeScript-only syntax. They are a JavaScript proposal. The stripper leaves them alone, hands them to V8, and V8 does not implement them yet:
@logged
^
SyntaxError: Invalid or unexpected token
No ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, no mention of TypeScript, no hint. If you are coming from NestJS or TypeORM, this is where the zero-build idea ends for now β and --experimental-transform-types does not rescue standard decorators either. Use tsx or a build step, and revisit in a year.
3. The import that TypeScript swears is fine
This is the one that cost me real time:
import { Invoice, invoiceTotal } from "./types.ts";
import { Invoice, invoiceTotal } from "./types.ts";
^^^^^^^
SyntaxError: The requested module './types.ts' does not provide an export named 'Invoice'
Invoice is an interface. tsc knows that and elides it from the emitted import, which is exactly why this line compiles cleanly and has worked in every build-step project you have ever written. Node has no such knowledge. It sees a named import, V8 asks the module for that name, the module does not have one, and you get a module error pointing at a line your type checker approved.
The fix is one keyword, and once you know it you write it automatically:
import { type Invoice, invoiceTotal } from "./types.ts";
verbatimModuleSyntax: true turns this from a runtime surprise into a compile-time error, which is where it belongs. It was in my tsconfig.json before I finished debugging it. The reason I am telling you at all is that the message talks about modules and exports, so I spent twenty minutes staring at my imports map instead of at the missing word type.
4. Import specifiers name the file on disk, not the one you plan to emit
import { toDisplay } from "./money.js"; // there is no money.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../esm/money.js'
Node resolves specifiers the way it always has, with no extension rewriting. Every import has to end in .ts, which is the opposite of what a tsc-based codebase looks like β there, imports end in .js precisely because that is what will exist after the build.
That makes it the biggest mechanical obstacle to migrating an existing project, and TypeScript 5.7 added the switch for it: allowImportingTsExtensions lets you write .ts in source, and rewriteRelativeImportExtensions rewrites those specifiers to .js on emit. You can have Node-native imports and still ship compiled output β I ran both paths over the same tree to be sure.
5. Path aliases do not exist, and node_modules is off limits
Node never reads tsconfig.json, so paths does nothing:
TypeError [ERR_PACKAGE_IMPORT_NOT_DEFINED]: Package import specifier "#billing/types.ts" is not defined
The replacement is the imports field in package.json β Node's own subpath imports, which TypeScript understands too. That is why the example above maps #src/*: one alias mechanism, honored by the runtime and the checker, no plugin in between.
The other limit is firmer. Type stripping is disabled under node_modules:
Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]: Stripping types is currently unsupported
for files under node_modules, for ".../node_modules/@acme/billing/index.ts"
Which means: do not publish a package whose entry point is a .ts file. Libraries still compile.
The nuance that matters for monorepos is the opposite of what the error suggests. A workspace package does work, because npm install symlinks it and Node resolves through the real path outside node_modules. I set up a two-package npm workspace with "exports": "./src/index.ts" and imported it across packages β it ran. The restriction is about installed copies, not about your internal packages.
The numbers
Best of 25 runs each, on an i7-1165G7 laptop with Windows 11 and Node 24.16. Startup-only workloads on a laptop are noisy, so the honest column is the minimum; the median sits next to it so you can see the spread.
| What is running | Min | Median |
|---|---|---|
20-byte .js file |
66 ms | 94 ms |
the same file as .ts
|
90 ms | 114 ms |
109 KB of emitted .js
|
69 ms | 75 ms |
the 229 KB .ts it came from |
153 ms | 191 ms |
| four-module service, compiled | 100 ms | 118 ms |
| four-module service, stripped | 130 ms | 176 ms |
four-module service, tsx
|
486 ms | 590 ms |
Two things fall out of that table.
The fixed cost is about 25 ms, which is instantiating a WebAssembly stripper you were not loading before. The marginal cost is roughly 0.26 ms per kilobyte of TypeScript: 229 KB in a single file costs 84 ms, a 20-byte file costs 24 ms. Strip cost tracks source size, not project size, so a service made of four small modules pays almost nothing.
tsx starts about 3.7x slower here, and it is still the right tool for some projects. That gap buys decorators, enums, path aliases and CommonJS interop. It is a fair trade β just one to make deliberately now, rather than by default.
The thing I actually tried to do
This blog is two standalone TypeScript scripts β one that publishes to the dev.to API, one that posts to LinkedIn and X β both run through tsx. They import nothing but node: builtins. Perfect candidates for deleting a dependency.
Both ran under bare node on the first attempt. Then I grepped for anything else mentioning the tool and found this, inside the publish script:
spawnSync("tsx", [socialScript, "--url", result.url, "--file", filePath], {
stdio: "inherit",
shell: true,
});
A hardcoded binary name, in a code path that only executes when you pass --social. Editing package.json would have "removed" tsx on a Tuesday and broken publishing the following Friday, in a branch of the code no type checker and no ordinary run ever touches. The fix is to spawn the interpreter that is already running:
spawnSync(process.execPath, [socialScript, "--url", result.url, "--file", filePath], {
stdio: "inherit",
});
process.execPath also makes shell: true unnecessary, which is a good return on a one-line change. The lesson generalizes past this repo: the dependency you are trying to drop is rarely only in package.json. Grep for the binary name before you celebrate.
When to use which
Bare node β scripts, CLIs, tests, small services, anything you write from scratch on Node 24. No dependencies, and one fewer moving part between your editor and the process.
tsx β decorators, enums or parameter properties you are not going to rewrite; path aliases you cannot convert; CommonJS dependencies with awkward interop; a codebase whose imports all end in .js and a migration you cannot schedule this quarter.
A real build step β publishing a library to npm, where the node_modules restriction is absolute; bundling or minifying for a deployment target; targeting a Node older than 22; or a cold-start budget where 25 ms of stripper plus a millisecond per four kilobytes of source is more than you can spend.
And keep tsc --noEmit in all three. Type stripping did not remove the need for a type checker. It removed the compiler from the path between saving a file and watching it run, which was the part that was slowing you down.
Key Takeaways
-
The flag is gone.
node server.tsruns on Node 24 with no flags,--experimental-strip-typesis a silent no-op, andprocess.features.typescriptreports"strip","transform"orfalse. - Types are overwritten with spaces, not deleted. Byte positions survive, so line and column numbers in a stack trace are exact with no source map β you can see the blanks in the echoed source line.
-
Nothing is type checked, so
tsc --noEmitstays. AdderasableSyntaxOnlyandverbatimModuleSyntaxso the checker rejects what Node will refuse, starting withimport { SomeInterface }missing itstypekeyword. -
Five things break: enums, namespaces and parameter properties (with a clear error), decorators (with a bare
SyntaxError: Invalid or unexpected token), imports naming.jsfiles that do not exist,tsconfigpath aliases, and any.tsfile installed undernode_modules. -
Budget about 25 ms plus 0.26 ms per KB of source β roughly 3.7x faster to start than
tsxon the same files, which is reason enough to reach for barenodeby default and fortsxon purpose.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.


