How to Run TypeScript Directly in Node.js with Erasable Syntax
- Install Node.js 23.6+ (or 22.6+ with
--experimental-strip-types) and TypeScript 5.8+. - Add
"erasableSyntaxOnly": trueand"verbatimModuleSyntax": trueto yourtsconfig.jsoncompiler options. - Set
"module": "nodenext"and"noEmit": trueintsconfig.json. - Replace all
enumdeclarations withas constobjects and derived union types. - Convert constructor parameter properties to explicit property declarations and assignments.
- Refactor value-bearing
namespaceblocks into standard ES module exports. - Run your TypeScript files directly with
node src/server.ts— no build step required. - Verify type safety by running
tsc --noEmitin CI, since Node.js strips types without checking them.
TypeScript 5.8 introduced the --erasableSyntaxOnly compiler flag, which validates whether TypeScript code contains only type constructs that can be stripped away without altering runtime behavior. Combined with Node.js 23.6+ and its native type stripping support, erasable syntax makes it possible to run .ts files directly with node server.ts, no build step required. This removes the compile step from TypeScript server-side development, cutting the build overhead that most TypeScript server-side workflows require, though tools like ts-node and tsx have long offered similar conveniences.
Table of Contents
What Is Erasable Syntax and Why Does It Matter?
The Problem with Traditional TypeScript Compilation
The standard TypeScript workflow compiles .ts files into .js files via tsc or a bundler before execution. This pipeline demands build tooling configuration, source map management for debugging, a tsconfig.json tuned to the target runtime, and often a watch process during development. For server-side Node.js projects, this overhead exists solely because the runtime cannot understand type annotations. The JavaScript that tsc emits is frequently almost identical to the source TypeScript, with types simply removed, yet the entire build apparatus must exist to perform that removal.
How Type Stripping Works
Node.js 22.6 shipped --experimental-strip-types, a flag that let the runtime load .ts files directly by stripping type annotations before execution. Node.js 23.6+ strips types without a flag, so .ts files run natively. The mechanism is direct: Node.js removes type syntax from the source text and executes the surviving JavaScript. It does not perform type checking.
This approach works only for syntax that is “erasable,” meaning constructs that can be deleted from the source without altering the runtime behavior of the program. A type annotation like : string is erasable because removing it leaves valid JavaScript. An enum declaration is not erasable because it generates runtime code that does not exist in the original source. Node.js cannot handle syntax that requires code transformation, only removal.
Node.js removes type syntax from the source text and executes the surviving JavaScript. It does not perform type checking.
function greet(name: string, age: number): string {
return `Hello, ${name}. You are ${age}.`;
}
const message: string = greet("Ada", 36);
console.log(message);
function greet(name, age ) {
return `Hello, ${name}. You are ${age}.`;
}
const message = greet("Ada", 36);
console.log(message);
The type annotations are erased in place (replaced with whitespace to preserve source positions), and the resulting code is valid JavaScript. No AST transformation, no code generation.
What --erasableSyntaxOnly Validates and Rejects
Syntax That Is Erasable (Allowed)
The --erasableSyntaxOnly flag permits any TypeScript construct that can be deleted without changing runtime semantics. This covers every type-level feature that most TypeScript codebases rely on daily.
function add(a: number, b: number): number {
return a + b;
}
interface User {
id: number;
name: string;
email: string;
}
type UserID = number;
function identity<T>(value: T): T {
return value;
}
const input = document.getElementById("name") as HTMLInputElement;
function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string {
return String(value);
}
declare const API_URL: string;
import type { Request, Response } from "express";
export type { User };
Every construct above vanishes entirely upon type erasure. The runtime JavaScript contains only the implementation signatures, function bodies, and value-level code.
Syntax That Is NOT Erasable (Rejected)
When --erasableSyntaxOnly is enabled, tsc reports errors for any construct that would require code emission rather than simple removal. Each of these features generates runtime JavaScript that does not exist in the source.
enum Status {
Active,
Inactive,
}
namespace Validation {
export function isValid(s: string) {
return s.length > 0;
}
}
class Person {
constructor(private name: string, public age: number) {}
}
import fs = require("fs");
function Log(target: any, key: string) {}
class Service {
@Log
greet() {}
}
In each case, the TypeScript compiler would normally emit JavaScript code that does not appear anywhere in the source file. Type stripping cannot produce that code because it only removes tokens.
Edge Cases and Gotchas
Several constructs sit at boundaries that are easy to misjudge.
const enum Direction {
Up,
Down,
}
namespace Shapes {
export interface Circle {
radius: number;
}
export interface Square {
side: number;
}
}
namespace Shapes {
export function area(radius: number) {
return Math.PI * radius * radius;
}
}
TC39 stage-3 decorators are a JavaScript language proposal, but TypeScript still emits JavaScript decorator-application code for them, making them also incompatible with --erasableSyntaxOnly. Any decorator usage currently requires a build step. Legacy decorators (experimentalDecorators) are additionally rejected.
Setting Up Your Project for Erasable Syntax
Prerequisites
Running TypeScript directly requires Node.js 22.6+ with --experimental-strip-types, or Node.js 23.6+ where type stripping runs without a flag. Node.js 22 is the current LTS and appropriate for production; 23.6+ offers unflagged stripping but is a non-LTS release. Check the Node.js release schedule for the current recommended version. TypeScript 5.8 or later must be installed to use the --erasableSyntaxOnly flag.
Additionally, install @types/node as a dev dependency to provide TypeScript type definitions for Node.js built-in modules, which are required for type checking server-side code with tsc --noEmit.
If using ES module syntax (import/export), package.json must include "type": "module", or files must use the .mts extension.
Configuring tsconfig.json
The erasableSyntaxOnly option is added to compilerOptions. Several companion settings reinforce the workflow.
// tsconfig.json — configured for erasable syntax with direct Node.js execution
{
"compilerOptions": {
// Core: enforce that all syntax is erasable
"erasableSyntaxOnly": true,
// Enforces that import/export syntax is preserved exactly as written
// and requires explicit 'import type' for type-only imports.
// With 'module: nodenext', also enforces per-file ESM/CJS mode
// via file extension or package.json 'type' field.
"verbatimModuleSyntax": true,
// Matches Node.js native ESM and CJS resolution behavior
"module": "nodenext",
// moduleResolution defaults to 'nodenext' when module is 'nodenext';
// shown here for explicitness.
"moduleResolution": "nodenext",
// Target the runtime you're using
"target": "esnext",
// Restrict lib to Node.js-relevant APIs (excludes DOM types)
"lib": ["esnext"],
// No emit — Node.js runs the .ts files directly; tsc is only for type checking
"noEmit": true,
// Defensive: if noEmit is ever removed, output goes to dist/ not src/
"rootDir": "src",
"outDir": "dist",
// Strict type checking (recommended regardless of erasable syntax)
"strict": true
},
"include": ["src/**/*.ts"]
}
The verbatimModuleSyntax setting is a natural complement to erasableSyntaxOnly. It enforces that imports and exports are written exactly as they should appear in the output, requiring explicit import type for type-only imports. This prevents scenarios where TypeScript would need to transform an import statement during compilation.
Running TypeScript Directly in Node.js
On Node.js 22.6 through 23.5, the experimental flag is required. On 23.6+, .ts files execute natively.
// package.json
{
"name": "ts-direct-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
// For Node.js 22.6–23.5:
"dev:legacy": "node --experimental-strip-types src/server.ts",
// For Node.js 23.6+:
"dev": "node src/server.ts",
// Type checking only (tsconfig.json already sets noEmit: true):
"typecheck": "tsc"
},
"devDependencies": {
"typescript": "5.8.3",
"@types/node": "22.15.3"
}
}
Note: Pin the TypeScript version and @types/node to exact versions to ensure reproducible behavior across installs. The caret range (^5.8.0) would permit future minor versions that could change --erasableSyntaxOnly behavior, and an unpinned @types/node can drift between installs.
$ node src/server.ts
Server listening on port 3000
No transpilation. No intermediate dist/ directory. The .ts file is the executable artifact.
Practical Implementation Walkthrough
Building a Small Node.js HTTP Server in Pure TypeScript
The following is a complete, runnable HTTP server using only erasable TypeScript syntax. It runs directly with node server.ts on Node.js 23.6+.
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
type Route = (req: IncomingMessage, res: ServerResponse) => void;
interface ApiResponse<T> {
status: number;
data: T;
}
function jsonResponse<T>(res: ServerResponse, body: ApiResponse<T>): void {
let serialized: string;
try {
serialized = JSON.stringify(body.data);
} catch {
const errorBody = JSON.stringify({ error: "Response serialization failed" });
res.writeHead(500, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(errorBody, "utf8"),
});
res.end(errorBody);
return;
}
const byteLength = Buffer.byteLength(serialized, "utf8");
res.writeHead(body.status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": byteLength,
});
res.end(serialized);
}
const healthCheck: Route = (_req, res) => {
jsonResponse<{ ok: boolean }>(res, { status: 200, data: { ok: true } });
};
const getUsers: Route = (_req, res) => {
const users: Array<{ id: number; name: string }> = [
{ id: 1, name: "Ada Lovelace" },
{ id: 2, name: "Grace Hopper" },
];
jsonResponse(res, { status: 200, data: users });
};
const routes: Record<string, Route> = {
"/health": healthCheck,
"/users": getUsers,
};
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const rawUrl = req.url ?? "https://www.sitepoint.com/";
const pathname = new URL(rawUrl, "http://localhost").pathname;
const handler = routes[pathname];
if (handler) {
handler(req, res);
} else {
jsonResponse(res, { status: 404, data: { error: "Not found" } });
}
});
server.requestTimeout = 5000;
server.on("error", (err: NodeJS.ErrnoException) => {
console.error(`Server error [${err.code ?? "UNKNOWN"}]: ${err.message}`);
process.exit(1);
});
server.listen(3000, () => {
console.log("Server listening on port 3000");
});
Every type annotation, interface, type alias, and generic in this file is erased at load time. What Node.js executes is plain JavaScript with whitespace where types once stood. The URL is parsed to extract the pathname, ensuring query strings do not prevent route matching, and req.url is guarded against undefined. The jsonResponse function catches serialization errors and includes a Content-Length header for correct HTTP/1.1 behavior. The server handles 'error' events (such as EADDRINUSE) and sets a request timeout to prevent slow-client resource exhaustion.
Refactoring Non-Erasable Patterns
Existing codebases will likely contain non-erasable constructs. Each has an erasable equivalent, shown below.
enum to as const object
enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE",
}
export const Status = {
Active: "ACTIVE",
Inactive: "INACTIVE",
} as const;
export type Status = (typeof Status)[keyof typeof Status];
Constructor parameter properties to explicit assignments
class User {
constructor(private name: string, public age: number) {}
}
class User {
private name: string;
public age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
Value namespace to module exports
namespace MathUtils {
export function double(n: number) {
return n * 2;
}
}
export function double(n: number): number {
return n * 2;
}
Each refactoring preserves identical runtime behavior while using only constructs that disappear under type erasure.
How This Relates to Vite, esbuild, and SWC
Tools like Vite (via esbuild), standalone esbuild, and SWC already perform type stripping rather than full TypeScript compilation. They remove type syntax and do not type-check. The --erasableSyntaxOnly flag acts as a compile-time safety net: enabling it in tsconfig.json ensures that code stays compatible with any tool that performs fast type stripping, not just Node.js. If a codebase passes tsc --noEmit with erasableSyntaxOnly enabled, it will work with esbuild and SWC, since both tools already strip types using the same erasure model. Edge cases may arise with newer TypeScript syntax that a specific tool version has not yet implemented, so pin your tool versions and test after upgrades.
If a codebase passes
tsc --noEmitwitherasableSyntaxOnlyenabled, it will work with esbuild and SWC, since both tools already strip types using the same erasure model.
React Component Considerations
JSX and TSX syntax is not erasable. The syntax requires transformation into React.createElement() calls or the automatic JSX runtime’s _jsx() function. This is code generation, not removal. Node.js provides an --experimental-transform-types flag that enables transformation of non-erasable TypeScript constructs such as enums and namespaces, but this flag does not add JSX support. Running .tsx files natively in Node.js is not supported; a bundler or custom loader (e.g., Vite, esbuild, or a custom Node.js --loader) is required.
The practical boundary is clear: use erasable syntax enforcement for server-side code, API layers, CLI tools, and scripts. React UI code running in the browser still requires a bundler (Vite, webpack, or similar) that handles JSX transformation, and the standard frontend toolchain remains appropriate there.
Implementation Checklist
A copy-friendly reference for teams adopting erasable syntax:
- Node.js 23.6+ installed (or 22.6+ with
--experimental-strip-typesflag) - TypeScript 5.8+ installed
@types/nodeinstalled as a dev dependency (for type checking Node.js APIs)"type": "module"set inpackage.json(if using ES module syntax)"erasableSyntaxOnly": trueadded totsconfig.jsoncompilerOptions"verbatimModuleSyntax": trueadded totsconfig.json"module": "nodenext"configured- All
enumdeclarations converted toas constobjects - All constructor parameter properties converted to explicit assignments
- All
namespaceblocks with runtime values refactored to modules - No legacy
import x = require(...)syntax remaining - No decorators of any kind (both legacy and TC39 decorators require code emission)
const enumreplaced with regularas constobjectspackage.jsonscripts updated to run.tsfiles directly- CI pipeline runs
tsc --noEmitfor type checking (no build step needed for execution) - Team coding guidelines updated to document erasable syntax constraints
Limitations and When NOT to Use This Approach
No Type Checking at Runtime
Node.js strips types. It does not check them. A file containing const x: number = "hello" will execute without error because the : number annotation is simply removed. Running tsc --noEmit in CI remains essential, which is why the implementation checklist above includes it as a required step. Type stripping is an execution strategy, not a replacement for the type checker.
Ecosystem Compatibility
Third-party libraries that export enum or namespace types in their API surface do not cause issues with --erasableSyntaxOnly in consuming code, since those constructs are compiled in the library’s own build. The constraint applies only to first-party source code. However, monorepos where packages reference each other’s .ts source directly (rather than compiled outputs) will need every package to comply with erasable syntax rules.
Production Readiness
For production deployments that require bundling, minification, or tree-shaking, a traditional build pipeline remains necessary. Type stripping produces no optimized output. It is not a bundler.
The approach works best for server-side Node.js applications, CLI tools, scripts, and development workflows where skipping the build step saves meaningful time: no rebuild-on-save latency, no tsconfig emit configuration, and fewer dev dependencies. For applications that ship to browsers, a build step is still required for asset optimization regardless of type stripping capabilities.
Key Takeaways
TypeScript 5.8’s --erasableSyntaxOnly flag creates a formal contract: if code compiles with this flag, it can execute via type stripping alone. Paired with Node.js 23.6+, this makes TypeScript a zero-build-step language for Node.js development.
TypeScript 5.8’s
--erasableSyntaxOnlyflag creates a formal contract: if code compiles with this flag, it can execute via type stripping alone.
The trade-off is giving up enum, parameter properties, value namespaces, and decorators (both legacy and TC39) in exchange for dropping the compile step. That trade-off lands differently depending on context: greenfield server projects absorb it easily, while large existing codebases with heavy enum usage face real migration work.
Adoption works well incrementally, starting with new server-side projects or internal tooling before migrating existing codebases. The official TypeScript 5.8 release notes and Node.js type stripping documentation provide canonical references for the specifics of both features.

