TypeScript 6 Erased My Build Step — And That Changes Everything

📅 April 29, 2026
TypeScript 6 Erased My Build Step — And That Changes Everything
👁 ... views

I just removed tsc, esbuild, ts-node, and tsx from a production project. All four. The devDependencies section got noticeably lighter, my CI pipeline dropped 12 seconds, and my code still runs exactly the same.

TypeScript 6.0, combined with Node.js’s native type stripping, means you can now run .ts files directly. No compilation step. No build folder. No watching for file changes and recompiling. Just node app.ts.

If you’ve been using TypeScript for more than a year, this probably sounds like heresy. I felt the same way. Then I tried it, and the cognitive overhead of maintaining a build step for a language that’s supposed to be “just JavaScript with types” finally clicked: we’ve been over-engineering this for a decade.

What Actually Changed

Node.js v23.6 unflagged experimental type stripping. TypeScript 5.8 introduced --erasableSyntaxOnly. TypeScript 6.0 made everything fast enough that this isn’t a gimmick anymore — it’s genuinely viable for production.

Here’s how it works at a high level: Node.js streams your .ts file, strips out the type annotations at runtime, and executes the remaining JavaScript. There’s no AST transformation, no source maps to generate, no output directory to manage. The stripping happens as the file loads, so the performance cost is negligible compared to spinning up a full transpiler.

// tsconfig.json — this is all you need now
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true
  }
}
# Run it. That's it.
node app.ts

No npm run build. No dist/ folder. No "main": "dist/index.js" in your package.json. Your .ts file is your entry point.

What “Erasable” Actually Means

This is the key concept that trips people up. Erasable syntax means: if you delete all the type annotations, the remaining code is still valid JavaScript that behaves identically.

// ✅ Erasable — types strip away cleanly
function greet(name: string): string {
  return `Hello, ${name}`;
}
// After stripping: function greet(name) { return `Hello, ${name}`; }
// Same behavior. Zero runtime cost.

// ✅ Erasable — interfaces disappear entirely
interface User {
  id: number;
  name: string;
  email: string;
}

const user: User = { id: 1, name: "Aymen", email: "[email protected]" };
// After stripping: const user = { id: 1, name: "Aymen", email: "[email protected]" };

// ✅ Erasable — type aliases are compile-time only
type Result<T> = { ok: true; value: T } | { ok: false; error: string };

function parse(input: string): Result<number> {
  const n = Number(input);
  if (isNaN(n)) return { ok: false, error: "Not a number" };
  return { ok: true, value: n };
}

Type annotations, interfaces, type aliases, generics — all of these exist only at compile time. The TypeScript type checker validates them, then they vanish. Your runtime JavaScript is unaffected.

What Breaks (And Why That’s a Good Thing)

The --erasableSyntaxOnly flag blocks syntax that generates runtime code. If you’re coming from an older TypeScript codebase, this is where you’ll feel some friction:

Enums Are Gone

// ❌ ERROR with erasableSyntaxOnly
enum Status {
  Active = "active",
  Inactive = "inactive",
  Pending = "pending"
}

Enums compile to IIFE wrappers — actual JavaScript objects. Type stripping can’t erase them without breaking behavior. The fix is simpler than you think:

// ✅ Use const objects + type extraction
const Status = {
  Active: "active",
  Inactive: "inactive",
  Pending: "pending",
} as const;

type Status = (typeof Status)[keyof typeof Status];

function handleStatus(status: Status) {
  // Fully typed, runtime-friendly, zero compilation overhead
}

This pattern is actually better than enums. You get tree-shaking, the values are real strings you can serialize, and you can iterate over them with Object.values(Status). I used to defend enums. I was wrong.

Parameter Properties Are Out

// ❌ ERROR — generates implicit this.x = x assignments
class UserService {
  constructor(private db: Database, private logger: Logger) {}
}

Parameter properties are a TypeScript convenience that generates JavaScript assignments. The fix is three extra lines:

// ✅ Explicit fields
class UserService {
  private db: Database;
  private logger: Logger;

  constructor(db: Database, logger: Logger) {
    this.db = db;
    this.logger = logger;
  }
}

Yes, it’s more verbose. It’s also more readable. When I’m reviewing a PR at 11 PM, I’d rather see explicit assignments than wonder what magic the compiler is doing behind my back.

Namespaces Are Dead Weight

// ❌ ERROR — compiles to IIFE wrappers
namespace Utils {
  export function format(x: number) { ... }
}

Namespaces were a workaround for the pre-module era. We have ES modules now. Use them:

// ✅ modules.ts
export function format(x: number) { ... }

// ✅ main.ts
import { format } from "./modules.js";

If you’re still using namespaces in 2026, you have bigger problems than type stripping.

Real-World Migration: My FastAPI-Style TypeScript API

Here’s what my actual project looks like after the migration. This is a FastAPI-inspired REST API — the kind of thing I usually build in Python, but the TypeScript version now has no build step:

// src/index.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import type { Context } from "hono";

// Import aliases via package.json#imports (not tsconfig paths)
import { db } from "#db";
import { authMiddleware } from "#middleware/auth";

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "viewer";
}

type ApiResponse<T> = {
  data: T;
  meta: { page: number; total: number };
} | {
  error: string;
  status: number;
};

const app = new Hono();

// Middleware
app.use("/*", authMiddleware);

// Typed route handlers
app.get("/api/users", async (c: Context) => {
  const page = Number(c.req.query("page")) || 1;
  const limit = 20;

  const users = await db.query<User>(`
    SELECT id, name, email, role
    FROM users
    ORDER BY created_at DESC
    LIMIT $1 OFFSET $2
  `, [limit, (page - 1) * limit]);

  const total = await db.queryOne<number>(
    "SELECT COUNT(*)::int FROM users"
  );

  return c.json({
    data: users,
    meta: { page, total },
  } satisfies ApiResponse<User[]>);
});

app.get("/api/users/:id", async (c: Context) => {
  const id = Number(c.req.param("id"));

  const user = await db.queryOne<User | null>(`
    SELECT id, name, email, role
    FROM users WHERE id = $1
  `, [id]);

  if (!user) {
    return c.json({ error: "User not found", status: 404 } satisfies ApiResponse<never>, 404);
  }

  return c.json({ data: user, meta: { page: 1, total: 1 } } satisfies ApiResponse<User>);
});

// Start — no build step needed
serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Server running on http://localhost:${info.port}`);
});
// package.json — note the imports field replacing tsconfig paths
{
  "name": "my-api",
  "type": "module",
  "imports": {
    "#db": "./src/database/index.ts",
    "#middleware/*": "./src/middleware/*.ts"
  },
  "scripts": {
    "dev": "node --watch src/index.ts",
    "start": "node src/index.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "hono": "^4.0.0",
    "@hono/node-server": "^1.13.0"
  },
  "devDependencies": {
    "typescript": "^6.0.0",
    "@types/node": "^22.0.0"
  }
}

My package.json scripts went from this:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc && esbuild dist/index.js --bundle --platform=node --outfile=dist/bundle.js",
    "start": "node dist/bundle.js",
    "typecheck": "tsc --noEmit"
  }
}

To this:

{
  "scripts": {
    "dev": "node --watch src/index.ts",
    "start": "node src/index.ts",
    "typecheck": "tsc --noEmit --erasableSyntaxOnly"
  }
}

Three scripts instead of four. No build artifacts. No dist/ folder to accidentally commit. The --watch flag is built into Node.js now — it restarts on file changes, same as tsx watch or nodemon.

The Counter-Arguments (Because There Are Always Some)

“But what about down-leveling to older ES versions?” — Fair point. Type stripping doesn’t transpile. If you need to support Node 18 or older browsers, you still need esbuild or swc. But if you’re targeting Node 22+ (which you should be in 2026), the runtime already supports everything TypeScript 6 emits. I haven’t needed to down-level a backend project since Node 16 went EOL.

“Type checking still requires tsc.” — Yes, and that’s fine. Run tsc --noEmit in CI. It’s fast — TypeScript 6 improved incremental compilation by 40-60%. You’re not running the full compiler pipeline, just the type checker. It takes 2 seconds on my 50-file project.

“What about bundle size for production?” — If you’re deploying a Node.js backend, bundle size rarely matters. You’re shipping to a server, not to a browser with a 3G connection. If you do need bundling (lambda cold starts, etc.), esbuild still works great — you just use it only when you need it, not for every npm run dev.

“My team uses enums everywhere.” — Then you have a migration project. But honestly, replacing enums with const objects + type extraction is a morning’s work with find-and-replace, and your codebase is better for it. The as const pattern gives you everything enums did, plus serializability and iteration.

The Bigger Picture: TypeScript Is Bifurcating

Here’s the take that’ll ruffle feathers: TypeScript is splitting into two languages.

There’s “erasable TypeScript” — the subset that runs everywhere, uses only type annotations, and needs no build step. This is what you should be writing for new projects.

And there’s “full TypeScript” — the complete language with enums, namespaces, parameter properties, and decorator transforms. This is what legacy codebases use, and it will need compilation for the foreseeable future.

The TypeScript team isn’t forcing this split. They’re just responding to reality: Node.js ships type stripping, browsers are working on “types as comments” (TC39 proposal), and the ecosystem is moving toward zero-build workflows. The --erasableSyntaxOnly flag is a guardrail, not a mandate. Use it if you want the no-build-step experience. Ignore it if you don’t.

But I’ll say this: every new TypeScript project I start in 2026 uses erasable syntax only. The friction of a build step was always there — I just got used to it. Now that it’s gone, I can’t unsee how much time and cognitive load it was costing me.

Bottom Line

TypeScript 6 + Node.js type stripping = .ts files that run directly. No tsc. No dist/. No build step. Your type checker runs in CI, your code runs as-is, and the gap between “writing TypeScript” and “running JavaScript” finally closes.

The migration isn’t free — you’ll need to replace enums, remove parameter properties, and drop namespaces. But each of those changes makes your codebase more explicit, more portable, and easier to reason about. The constraints are features, not limitations.

Are you running TypeScript without a build step yet? What’s keeping you from trying it — or what convinced you to make the switch?



📚 Continue Reading

Supporting the blog through affiliate links (at no extra cost to you):

💡

Enjoying the content? Here are tools I personally use and recommend:

  • 🌐 Hosting: Bluehost — what this blog runs on
  • 🛒 Tech Gear: My Amazon Store — keyboards, monitors, dev tools I use

Purchases through my links help keep this blog ad-free 💙

Enjoyed this post?

Subscribe to the newsletter or follow on YouTube for more dev content.

🎬 Watch Shorts