This metrics tool terrifies bad developers

Start free trial
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

How to Run TypeScript Directly in Node.js with Erasable Syntax

  1. Install Node.js 23.6+ (or 22.6+ with --experimental-strip-types) and TypeScript 5.8+.
  2. Add "erasableSyntaxOnly": true and "verbatimModuleSyntax": true to your tsconfig.json compiler options.
  3. Set "module": "nodenext" and "noEmit": true in tsconfig.json.
  4. Replace all enum declarations with as const objects and derived union types.
  5. Convert constructor parameter properties to explicit property declarations and assignments.
  6. Refactor value-bearing namespace blocks into standard ES module exports.
  7. Run your TypeScript files directly with node src/server.ts — no build step required.
  8. Verify type safety by running tsc --noEmit in 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.

// before-stripping.ts — TypeScript source
function greet(name: string, age: number): string {
  return `Hello, ${name}. You are ${age}.`;
}

const message: string = greet("Ada", 36);
console.log(message);
// after-stripping — what Node.js effectively executes
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.

// erasable-syntax-showcase.ts — all constructs here are erasable

// Type annotations on parameters, return types, and variables
function add(a: number, b: number): number {
  return a + b;
}

// Interfaces
interface User {
  id: number;
  name: string;
  email: string;
}

// Type aliases
type UserID = number;

// Generics
function identity<T>(value: T): T {
  return value;
}

// `as` type assertions
const input = document.getElementById("name") as HTMLInputElement;

// Function overload signatures (only the signatures, not the implementation)
function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string {
  return String(value);
}

// `declare` statements (ambient declarations)
// teaching example only — not runnable in the server context
declare const API_URL: string;

// Type-only imports and exports
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.

// non-erasable.ts — each construct triggers a tsc error with --erasableSyntaxOnly

// ❌ enum: generates a runtime object with reverse mappings
// Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
enum Status {
  Active,
  Inactive,
}

// ❌ namespace with runtime values: emits an IIFE
// Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
namespace Validation {
  export function isValid(s: string) {
    return s.length > 0;
  }
}

// ❌ Constructor parameter properties: generates property assignment code
// Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
class Person {
  constructor(private name: string, public age: number) {}
}

// ❌ CJS-style import assignment: requires module system transformation
// Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
import fs = require("fs");

// ❌ Legacy experimental decorators: requires wrapper code generation
// Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
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: values are inlined at usage sites, which is code transformation
// Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
const enum Direction {
  Up,
  Down,
}
// A usage like `Direction.Up` would be replaced with `0` — that's transformation, not erasure.

// ✅ Type-only namespace: contains no runtime values, purely type-level
namespace Shapes {
  export interface Circle {
    radius: number;
  }
  export interface Square {
    side: number;
  }
}
// This is erasable — the entire block disappears.
// Caution: if the same namespace name is declared elsewhere with runtime values,
// the value declaration makes the entire merged namespace non-erasable.

// ❌ Value namespace: contains executable code
namespace Shapes {
  export function area(radius: number) {
    return Math.PI * radius * radius;
  }
}
// This requires an IIFE to be emitted — not erasable.

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

// server.ts — runs directly with: node server.ts
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";

// Type alias — erasable
type Route = (req: IncomingMessage, res: ServerResponse) => void;

// Interface — erasable
interface ApiResponse<T> {
  status: number;
  data: T;
}

// Generic utility function — type annotations and generics are erasable
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);
}

// Route handlers with typed parameters — all erasable
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 });
};

// Router using a typed map — erasable
const routes: Record<string, Route> = {
  "/health": healthCheck,
  "/users": getUsers,
};

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
  // req.url is string | undefined; guard and strip query string before lookup
  const rawUrl = req.url ?? "/";
  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; // ms; prevent slow-client resource exhaustion

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

// ❌ Non-erasable: enum generates runtime code
enum Status {
  Active = "ACTIVE",
  Inactive = "INACTIVE",
}

// ✅ Erasable equivalent: as const object + type extraction
export const Status = {
  Active: "ACTIVE",
  Inactive: "INACTIVE",
} as const;
export type Status = (typeof Status)[keyof typeof Status];
// Status type is "ACTIVE" | "INACTIVE" — fully erasable

Constructor parameter properties to explicit assignments

// ❌ Non-erasable: parameter properties generate assignment code
class User {
  constructor(private name: string, public age: number) {}
}

// ✅ Erasable equivalent: explicit property declarations and assignments
class User {
  private name: string;
  public age: number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

Value namespace to module exports

// ❌ Non-erasable: namespace with values emits an IIFE
namespace MathUtils {
  export function double(n: number) {
    return n * 2;
  }
}

// ✅ Erasable equivalent: plain module exports (in a separate file or at top level)
export function double(n: number): number {
  return n * 2;
}

Each refactoring preserves identical runtime behavior while using only constructs that disappear under type erasure.

Integration with React and Frontend Tooling

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 --noEmit with erasableSyntaxOnly enabled, 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 <Component /> 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-types flag)
  • TypeScript 5.8+ installed
  • @types/node installed as a dev dependency (for type checking Node.js APIs)
  • "type": "module" set in package.json (if using ES module syntax)
  • "erasableSyntaxOnly": true added to tsconfig.json compilerOptions
  • "verbatimModuleSyntax": true added to tsconfig.json
  • "module": "nodenext" configured
  • All enum declarations converted to as const objects
  • All constructor parameter properties converted to explicit assignments
  • All namespace blocks 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 enum replaced with regular as const objects
  • package.json scripts updated to run .ts files directly
  • CI pipeline runs tsc --noEmit for 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 --erasableSyntaxOnly flag 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.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.