Mert Özen Further With Every Line
Backend Node.js Fundamentals — #05

TypeScript in Node.js: Native Type Stripping, erasableSyntaxOnly and Build-Free Development

Mert Özen Nov 14, 2026 10 min 8 views
TypeScript in Node.js: Native Type Stripping, erasableSyntaxOnly and Build-Free Development

Node.js 26 runs .ts files without a build step by replacing types with whitespace, but it does not check them, so tsc --noEmit must be a separate, mandatory step. With real output: a file with a type error printing 699.8, enum and parameter property errors, mandatory import type, the tsconfig Node recommends, moving the Bookstore API to TypeScript 7, imports instead of paths, the ban on .ts in node_modules, and when you still need a build.

Version note: The examples in this post were run with Node.js 26.13.1 (LTS), TypeScript 7.0.2 and @types/node 26.6.6.

Guess First

In this post we convert the API of Bookstore, the online bookstore used as the shared example across this site's series, to TypeScript. First a small file. In the Book type price is a number, but someone wrote the price as a string:

interface Book {
    isbn: string;
    title: string;
    price: number;
}

const dune: Book = { isbn: "9780441172719", title: "Dune", price: "349.90" };
console.log(`Two copies of ${dune.title}: ${dune.price * 2}`);

Node 26 can run .ts files directly. What happens when you run this file with node guess.ts: does Node catch the type error and stop, or does it run the file? If it runs, what is the output?

node labs/05/guess.ts
Two copies of Dune: 699.8

It ran, and with a plausible-looking result: JavaScript converted "349.90" * 2 to a number and got 699.8. The type error only appears when you ask the TypeScript compiler:

npx tsc -p labs/05
labs/05/guess.ts(7,60): error TS2322: Type 'string' is not assignable to type 'number'.

The main idea of this post is in those two outputs: Node's TypeScript support erases types, it does not check them. Running code without a build step is a big convenience, but if you do not set up type checking as a separate, mandatory step, you lose the guarantee TypeScript gives you. The TypeScript Basics series post on setting up TypeScript showed node file.ts for a single file; here we apply the same approach to a server project.

How Does Type Stripping Work?

When Node loads a .ts file it replaces type annotations, interfaces, type declarations and import type lines with whitespace and runs the rest as JavaScript. No transformation, no polyfills, and tsconfig.json is not read. The Node documentation gives the history: it arrived behind a flag in 22.6, was enabled by default in 22.18 and 23.6, and became stable in 24.12 and 25.2. Node 26 removed the --experimental-transform-types flag that transformed syntax such as enums:

node --experimental-transform-types order-status-enum.ts
node: bad option: --experimental-transform-types

Replacing types with whitespace is a small but important detail: line and column numbers stay the same. That is why Node does not generate source maps, and stack traces point straight at the location in the .ts file:

interface OrderItem {
    isbn: string;
    quantity: number;
}

function checkQuantity(item: OrderItem): void {
    if (item.quantity < 1) {
        throw new RangeError(`Quantity must be at least 1 for ${item.isbn}`);
    }
}

checkQuantity({ isbn: "9780141439600", quantity: 0 });
RangeError: Quantity must be at least 1 for 9780141439600
    at checkQuantity (file:///home/user/bookstore-api/labs/05/stack.ts:8:15)
    at file:///home/user/bookstore-api/labs/05/stack.ts:12:1

The error is at line 8, column 15, exactly where the throw is in the file. The module system is decided as for JavaScript: a .ts file loads according to the type in the nearest package.json, .mts is always ESM, .cts always CommonJS. .tsx is not supported.

Unsupported Syntax: enum, Parameter Properties, namespace and Decorators

Type stripping only supports what it can erase. TypeScript features that need JavaScript code generated in order to work raise an error. The two you will meet most often:

enum OrderStatus {
    Pending,
    Paid,
    Shipped,
}

console.log(OrderStatus.Paid);
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
class BookService {
    constructor(private readonly currency: string) {}

    format(amount: number): string {
        return `${amount.toFixed(2)} ${this.currency}`;
    }
}

console.log(new BookService("TRY").format(349.9));
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode

For someone coming from C# these two hurt; the primary constructor habit maps exactly onto parameter properties. The same group includes namespaces that contain runtime code and import aliases of the form import x = require(). A namespace that only holds types works fine. Decorators are a different case: because they are still a proposal at TC39, Node does not even recognise them and gives a plain syntax error:

file:///home/user/bookstore-api/labs/05/decorator.ts:3
    @logged
    ^

SyntaxError: Invalid or unexpected token

The erasable counterpart of an enum is an as const object plus a union type derived from it. It can be used both as a value at runtime and as a type at compile time:

// The erasable alternative: a const object plus a union type
export const OrderStatus = {
    Pending: "Pending",
    Paid: "Paid",
    Shipped: "Shipped",
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

const status: OrderStatus = OrderStatus.Paid;
console.log(status);
Paid

The TypeScript Intermediate series post on non-erasable syntax covers these features and why they are not erasable. What matters is catching them in the editor and in CI, not when Node runs. The erasableSyntaxOnly setting does that; we will see it shortly.

import type and verbatimModuleSyntax

Node erases types by looking at a single file; it cannot know whether a name you import is a type or a value. That is why marking type imports with type is required:

import { Book, describe } from "./book-types.ts";

const hobbit: Book = { isbn: "9780547928227", title: "The Hobbit" };
console.log(describe(hobbit));
SyntaxError: The requested module './book-types.ts' does not provide an export named 'Book'

Book is an interface; since it is erased, there is no such export at runtime. The correct form is import { type Book, describe } or a separate import type line. With verbatimModuleSyntax on in tsconfig, tsc tells you in advance: error TS1484: 'Book' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

tsconfig: The Settings Node Recommends

The Node documentation recommends a few settings for use with type stripping. The Bookstore API's tsconfig.json adds two lines on top of them:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "noEmit": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "types": ["node"]
  },
  "include": ["src", "packages/*/src"]
}

module: nodenext makes TypeScript apply Node's resolution rules (mandatory extensions, exports, imports) exactly. With noEmit, tsc produces no files and only checks; Node does the running anyway. rewriteRelativeImportExtensions allows the .ts extension in relative imports. erasableSyntaxOnly turns enums and parameter properties into compile errors, and verbatimModuleSyntax makes import type mandatory. Of the two lines I added, resolveJsonModule is for the JSON import. types is needed because of a default that changed in TypeScript 6: @types packages are no longer loaded automatically, and the default is an empty list. I did not write strict, because it has been on by default since TypeScript 6; the TypeScript Basics series post on tsconfig covers those defaults in detail.

Checking the files in the lab folder with these settings, every error Node gave at runtime shows up at compile time:

labs/05/guess.ts(7,60): error TS2322: Type 'string' is not assignable to type 'number'.
labs/05/missing-type-keyword.ts(1,10): error TS1484: 'Book' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
labs/05/order-status-enum.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
labs/05/param-props.ts(2,17): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

Converting the Bookstore API to TypeScript

The packages come in as devDependencies: TypeScript 7.0.2 and @types/node 26.6.6, which describes Node 26's APIs. TypeScript 7's compiler is a native binary; npm picks the package for your operating system as an optional dependency.

npm install -D typescript@7.0.2 @types/node@26.6.6

First the domain types. Since they are only types, they leave no trace at runtime:

export type Currency = "TRY" | "EUR";

export interface Book {
    isbn: string;
    title: string;
    author: string;
    price: number;
    currency: Currency;
}
import type { OrderStatus } from "@bookstore/contracts";
import type { Book } from "./book.ts";

export interface OrderItem {
    isbn: Book["isbn"];
    quantity: number;
    unitPrice: number;
}

export interface Order {
    id: string;
    customerId: string;
    status: OrderStatus;
    items: OrderItem[];
    createdAtUtc: string;
}

export function orderTotal(order: Order): number {
    return order.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
}

The OrderStatus type comes from the workspace package. I converted @bookstore/contracts to .ts as well; order statuses are now an as const array and a union derived from it instead of an enum:

export const ORDER_STATUSES = ["Pending", "Paid", "Shipped", "Delivered", "Cancelled"] as const;
export type OrderStatus = (typeof ORDER_STATUSES)[number];

With the files now .ts, these are the lines that changed in package.json. In the imports map we set up in post 03 only the extension changed; imports in the code such as #routes/health stayed exactly as they were:

"main": "src/server.ts",
"imports": {
  "#routes/*": "./src/routes/*.ts",
  "#services/*": "./src/services/*.ts",
  "#domain/*": "./src/domain/*.ts",
  "#db/*": "./src/db/*"
},
"scripts": {
  "start": "node src/server.ts",
  "dev": "node --watch --env-file-if-exists=.env src/server.ts",
  "typecheck": "tsc",
  "versions": "node -p process.versions.node"
}

Routes and services gained types; the types for node:http come in through import type:

// In-memory catalog until the SQL Server post; the JSON file is loaded once at startup
import type { Book } from "#domain/book";
import books from "#db/books.json" with { type: "json" };

const catalog: readonly Book[] = books as Book[];

export function listBooks(): readonly Book[] {
    return catalog;
}

export function findBook(isbn: string): Book | undefined {
    return catalog.find((b) => b.isbn === isbn);
}
import type { IncomingMessage, ServerResponse } from "node:http";
import { toBookSummary } from "@bookstore/contracts";
import { listBooks } from "#services/bookService";

// GET /api/books
export function handleListBooks(req: IncomingMessage, res: ServerResponse): void {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(listBooks().map(toBookSummary)));
}
npm run typecheck
node src/server.ts
> bookstore-api@0.1.0 typecheck
> tsc
Bookstore API listening on http://localhost:3000 (development)

tsc finished without printing anything, so there are no errors; with TypeScript 7's native compiler it took about 0.3 seconds for this project. To see that the check really works, I deliberately wrote price.amount.toUpperCase() in the route:

src/routes/books.ts(8,81): error TS2339: Property 'toUpperCase' does not exist on type 'number'.

In CI, npm run typecheck should be a separate step that runs before the tests. Locally, make sure your editor works with the same tsconfig; that is the only way to see early a type error that Node would run silently.

imports Instead of paths, and No .ts in node_modules

Because Node does not read tsconfig.json, aliases defined with compilerOptions.paths are not resolved at runtime; TypeScript compiles, and Node throws ERR_MODULE_NOT_FOUND. The Node documentation also points to subpath imports starting with # as the closest equivalent. That is why we chose the imports field in post 03: Node and tsc both read the same map, and there is no need to define aliases in two places.

The second limit is node_modules. To discourage package authors from publishing TypeScript sources, Node refuses to run .ts files under node_modules. So how did our workspace package work? npm links it into node_modules as a symlink, and by default Node uses the symlink's real path, so it sees the file as packages/contracts/src/index.ts. If we tell it to keep the symlink path, the rule kicks in:

node --preserve-symlinks src/server.ts
Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]: Stripping types is currently unsupported for files under node_modules, for "file:///home/user/bookstore-api/node_modules/@bookstore/contracts/src/index.ts"
  code: 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'

For workspaces inside the same repo this is not a problem. But if you ever publish @bookstore/contracts to the npm registry, your users will load it from node_modules, so you need to publish compiled .js and .d.ts files.

When Do You Still Need a Build?

Type stripping is enough for application code, but four cases need a compiler or a separate tool:

  • Published packages: because of the node_modules rule, tsc produces .js and type declarations; rewriteRelativeImportExtensions rewrites the .ts imports to .js along the way.
  • Frameworks that use decorators: frameworks such as NestJS rely on decorators and on the type metadata the compiler emits, so they need a build with tsc.
  • A codebase that cannot give up enums or parameter properties: as the Node documentation also suggests, a package such as tsx gives full TypeScript support.
  • JSX, or compiling down to an older JavaScript version: type stripping transforms no syntax.

The Bookstore API falls into none of these, so we continue without a build for the rest of the series. In the container image it is enough to copy the .ts files as they are and start with node src/server.ts; the TypeScript compiler is only needed for the check step in CI.

In Production

Do not deploy without a type check. Node will happily run code with a type error; when npm run typecheck fails in the pipeline, the deploy should stop.

Type stripping has a cost too: Node passes every .ts file through a parser that runs on WebAssembly when it loads it. That extra work happens only when modules are first loaded, not per request. If startup time is critical for you, measure the difference between .js and .ts in your own project.

Keep the major version of @types/node equal to the major version of the Node you run. Running on 26 with 24's types means you miss new APIs or use an API that was removed.

Do not put TypeScript into the production image. The compiler, the types and tsconfig.json are on the devDependency side; they are absent from an image installed with npm ci --omit=dev, and they do not need to be there.

Common Pitfalls

Mistaking type stripping for type checking. The "349.90" * 2 in Guess First silently produced 699.8; in a sum the same mistake would have turned into string concatenation.

Forgetting the .ts extension in imports. In a relative import in Node you write the name of the file Node will read: ./book.ts. If you write ./book.js, the file is not found.

Not writing import type. Importing a name that is a type with a normal import gives a does not provide an export named error at runtime; keep verbatimModuleSyntax on.

Relying on tsconfig paths. tsc is happy, Node cannot find the file. Define aliases in imports in package.json.

Going back to enums in type definitions. A single enum written out of habit is enough to crash the process at startup; erasableSyntaxOnly catches it at compile time.

A Small Experiment

★☆☆ Rewrite labs/05/param-props.ts without a parameter property, using a separate field and an assignment in the constructor, and try it with both node and npx tsc -p labs/05.

★★☆ Add a GET /api/orders/sample endpoint that uses orderTotal from src/domain/order.ts. Deliberately write an OrderItem with a wrong type and note at which step the error is caught.

★★★ Make @bookstore/contracts publishable: write a separate tsconfig.build.json that produces .js and .d.ts into a dist/ folder with tsc, add the types and default conditions to exports, and show that the error goes away when running with --preserve-symlinks.

The code is typed and runs without a build. In the next post we go to the heart of the server: the libuv phases, the difference between process.nextTick and setImmediate, which work goes to the thread pool, and moving Bookstore's price report to a worker thread without blocking the event loop.