Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 14 min read

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 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.

The same line of code twice: type annotation blocks on top, equal-width blanks below, columns still aligned

"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. Not paths, not experimentalDecorators, not target. 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.

Four source files feeding a runtime badge that fans out to watch, test and run, with no build tool in between

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.

Type annotations and generics passing through a gap in a wall while enums, namespaces, parameter properties, decorators and an installed package are stopped

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.

Three startup bars: compiled output shortest, type stripping slightly longer with a hatched fixed cost, tsx about four times the shortest

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.ts runs on Node 24 with no flags, --experimental-strip-types is a silent no-op, and process.features.typescript reports "strip", "transform" or false.
  • 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 --noEmit stays. Add erasableSyntaxOnly and verbatimModuleSyntax so the checker rejects what Node will refuse, starting with import { SomeInterface } missing its type keyword.
  • Five things break: enums, namespaces and parameter properties (with a clear error), decorators (with a bare SyntaxError: Invalid or unexpected token), imports naming .js files that do not exist, tsconfig path aliases, and any .ts file installed under node_modules.
  • Budget about 25 ms plus 0.26 ms per KB of source β€” roughly 3.7x faster to start than tsx on the same files, which is reason enough to reach for bare node by default and for tsx on purpose.
πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.