writing/tutorial/2026/08
TutorialAug 3, 2026·28 min read

Node.js 26 Native TypeScript: Ship a Production API With Zero Build Step

Node.js 26 runs TypeScript directly — no tsc, no bundler, no dist folder. Build a complete REST API with node:http, node:sqlite and node:test, learn the six syntax rules that will break your code, and deploy it in a single-stage Docker image.

For a decade, running TypeScript on a server meant accepting a build step. You installed typescript, configured tsc, produced a dist/ folder, and then spent a surprising amount of your life debugging why the thing you ran was not the thing you wrote. Or you reached for ts-node, then tsx, and traded build time for cold-start time.

Node.js 26 ends that arrangement. Type stripping is stable and on by default — node server.ts simply works. No loader, no flag, no compiler.

But "it just works" hides a sharp edge. Node.js 26 also removed the --experimental-transform-types flag entirely. If your code uses enums, decorators, or parameter properties, and you relied on Node to transform them, that code no longer runs. This tutorial teaches you both halves: how to build a real production service with zero build step, and exactly which TypeScript features you must abandon to get there.

Prerequisites

Before starting, ensure you have:

  • Node.js 26.0.0 or later installed (check with node --version)
  • TypeScript 5.8+ available as a dev dependency for type checking
  • Comfort with ES modules, async/await, and basic REST concepts
  • A code editor with TypeScript support (VS Code recommended)
  • Docker installed, if you want to follow the deployment step

You do not need: ts-node, tsx, nodemon, a bundler, or a dist/ folder.

What You'll Build

A complete task management REST API with:

  • CRUD endpoints over plain node:http — no Express, no Fastify
  • Persistence via node:sqlite, the built-in SQLite driver
  • Runtime request validation written as plain functions
  • A test suite running on node --test
  • Type checking enforced in CI as a separate gate
  • A single-stage Docker image with zero build artifacts

The finished service has zero runtime dependencies. The only thing in node_modules is TypeScript itself, and it never runs in production.

Step 1: Understand What Actually Changed

Before writing code, get the mental model right. Node.js does not compile your TypeScript. It erases it.

When Node loads a .ts file, a module called amaro (a thin wrapper around the SWC parser) removes every type annotation and replaces it with whitespace. Line numbers and column offsets stay byte-for-byte identical, which is why you do not need source maps — a stack trace already points at the right line in your original file.

Verify your setup:

node --version
# v26.0.0 or later

Create a scratch file to confirm stripping works:

// scratch.ts
type Greeting = { name: string; formal: boolean };
 
function greet({ name, formal }: Greeting): string {
  return formal ? `Good evening, ${name}.` : `Hey ${name}!`;
}
 
console.log(greet({ name: "Amira", formal: true }));

Run it directly:

node scratch.ts
# Good evening, Amira.

No flag. No config. That is the whole feature.

Two important corollaries follow from "erase, don't compile":

Node performs no type checking whatsoever. The runtime will happily execute code with completely broken types. const x: number = "hello" runs fine. Type safety becomes a CI concern, not a runtime one — we handle that in Step 9.

Anything that needs generated runtime code is impossible. An enum is not just a type; it compiles down to a real JavaScript object. There is nothing to erase, so Node refuses. This is the source of every migration headache below.

The timeline, so you know what to expect on older runtimes

Node versionStatus of type stripping
22.xBehind --experimental-strip-types
23.xUnflagged, still experimental
24.xDefault for .ts files
25.2+Marked stable
26.xStable, and --experimental-transform-types removed

If you need to disable it for any reason, the flag is --no-strip-types.

Step 2: Project Setup

Create the project skeleton:

mkdir task-api && cd task-api
npm init -y
npm install --save-dev typescript @types/node

That is the entire dependency list. Now edit package.json:

{
  "name": "task-api",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=26.0.0"
  },
  "scripts": {
    "dev": "node --watch --env-file-if-exists=.env src/server.ts",
    "start": "node --env-file-if-exists=.env src/server.ts",
    "test": "node --test 'src/**/*.test.ts'",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^5.8.0",
    "@types/node": "^26.0.0"
  }
}

Note "type": "module". Native type stripping works best with ES modules, and the whole ecosystem has moved there. Note also that dev and start point at the same TypeScript file — there is no separate production entrypoint.

The tsconfig that matters

This is the most important file in the project. Get it wrong and TypeScript will happily accept code that Node refuses to run.

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["esnext"],
    "types": ["node"],
 
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "rewriteRelativeImportExtensions": true,
    "allowImportingTsExtensions": true,
 
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

Four of those options exist specifically to keep you honest:

erasableSyntaxOnly is the star. It makes tsc reject any syntax Node cannot strip — enums, namespaces with runtime members, parameter properties. Without it, you find out at runtime in production. With it, you find out in your editor.

verbatimModuleSyntax forces you to write import type explicitly. This is not stylistic. Node's stripper is a parser, not a type checker — it has no idea whether Task in import { Task } from './types.ts' is a type or a value. If it is a type and you did not say so, Node emits a real import at runtime, finds no such export, and throws.

rewriteRelativeImportExtensions and allowImportingTsExtensions let you write .ts in import paths, which Node requires.

noEmit: true because tsc never produces output here. It is a linter now.

Step 3: The Six Rules That Will Break Your Code

Every migration to native type stripping hits the same six walls. Learn them before you write a line.

Rule 1: No enums

// BROKEN — Node throws a SyntaxError
enum Status {
  Pending = "pending",
  Done = "done"
}

An enum generates a runtime object. Use a const object with a derived type instead — it is smaller, tree-shakeable, and produces better error messages:

// src/types.ts
export const Status = {
  Pending: "pending",
  Done: "done",
  Archived: "archived"
} as const;
 
export type Status = (typeof Status)[keyof typeof Status];
// type Status = "pending" | "done" | "archived"

You use it almost identically: Status.Pending for the value, Status for the type.

Rule 2: Explicit .ts extensions on every relative import

import { createTaskStore } from "./store";     // BROKEN
import { createTaskStore } from "./store.ts";  // Correct

This trips up everyone coming from a bundler. Node's module resolution does not guess extensions.

Rule 3: import type is mandatory for types

// BROKEN at runtime — Node emits a real import for a type-only export
import { Task } from "./types.ts";
 
// Correct
import type { Task } from "./types.ts";
 
// Correct — mixed import, inline type marker
import { Status, type Task } from "./types.ts";

verbatimModuleSyntax catches this at type-check time.

Rule 4: No tsconfig.json path aliases

Node ignores compilerOptions.paths completely. That clever @/utils/logger alias resolves in your editor and explodes at runtime. Use relative paths, or Node's own imports field in package.json:

{
  "imports": {
    "#store/*": "./src/store/*"
  }
}

This is real Node resolution, so it works at runtime and tsc understands it too.

Rule 5: No decorators, no parameter properties

Decorators are still a TC39 Stage 3 proposal. Node does not polyfill them and will not until JavaScript ships them natively. Parameter properties fall to the same rule:

// BROKEN — parameter property generates an assignment
class TaskStore {
  constructor(private db: DatabaseSync) {}
}
 
// Correct — write the assignment yourself
class TaskStore {
  readonly #db: DatabaseSync;
 
  constructor(db: DatabaseSync) {
    this.#db = db;
  }
}

This one has real consequences: NestJS, TypeORM, and legacy class-validator cannot run under native stripping. They are built on decorator metadata. If you depend on them, keep your build step.

Rule 6: No JSX

Type stripping handles types, not syntax transforms. .tsx files still need a bundler. This is a server-side tutorial, so it does not bite us — but it is why Next.js and Vite are not going anywhere.

The short version: if your TypeScript would still be valid TypeScript after deleting every type annotation, Node can run it. If it would leave behind something that needs generating, it cannot.

Step 4: The Data Layer With node:sqlite

Node ships a SQLite driver in core. As of Node 25.7 it is marked Release Candidate (stability 1.2) rather than fully stable — worth knowing before you bet a critical system on it, though the API has been settled for several releases.

Create src/types.ts:

export const Status = {
  Pending: "pending",
  Done: "done",
  Archived: "archived"
} as const;
 
export type Status = (typeof Status)[keyof typeof Status];
 
export interface Task {
  id: number;
  title: string;
  status: Status;
  createdAt: string;
}
 
export interface NewTask {
  title: string;
  status?: Status;
}

Now src/store.ts:

import { DatabaseSync } from "node:sqlite";
import type { Task, NewTask, Status } from "./types.ts";
 
export function createTaskStore(path: string) {
  const db = new DatabaseSync(path);
 
  // STRICT tables reject type mismatches instead of silently coercing
  db.exec(`
    CREATE TABLE IF NOT EXISTS tasks (
      id        INTEGER PRIMARY KEY AUTOINCREMENT,
      title     TEXT NOT NULL,
      status    TEXT NOT NULL DEFAULT 'pending',
      createdAt TEXT NOT NULL
    ) STRICT
  `);
 
  // Prepared statements are compiled once and reused for every call
  const insertStmt = db.prepare(
    `INSERT INTO tasks (title, status, createdAt)
     VALUES (:title, :status, :createdAt)`
  );
  const listStmt = db.prepare(`SELECT * FROM tasks ORDER BY id DESC`);
  const getStmt = db.prepare(`SELECT * FROM tasks WHERE id = :id`);
  const updateStmt = db.prepare(
    `UPDATE tasks SET status = :status WHERE id = :id`
  );
  const deleteStmt = db.prepare(`DELETE FROM tasks WHERE id = :id`);
 
  return {
    list(): Task[] {
      return listStmt.all() as unknown as Task[];
    },
 
    get(id: number): Task | undefined {
      return getStmt.get({ id }) as unknown as Task | undefined;
    },
 
    create(input: NewTask): Task {
      const createdAt = new Date().toISOString();
      const result = insertStmt.run({
        title: input.title,
        status: input.status ?? "pending",
        createdAt
      });
 
      return {
        id: Number(result.lastInsertRowid),
        title: input.title,
        status: input.status ?? "pending",
        createdAt
      };
    },
 
    updateStatus(id: number, status: Status): Task | undefined {
      const result = updateStmt.run({ id, status });
      if (result.changes === 0) return undefined;
      return this.get(id);
    },
 
    remove(id: number): boolean {
      return deleteStmt.run({ id }).changes > 0;
    },
 
    close(): void {
      db.close();
    }
  };
}
 
export type TaskStore = ReturnType<typeof createTaskStore>;

A few things worth calling out. node:sqlite is synchronous by designDatabaseSync blocks the event loop. For SQLite that is usually correct, because a local disk read finishes faster than the overhead of scheduling an async callback. But it means a slow query stalls your whole server, so keep queries indexed and avoid full table scans.

Named parameters use allowBareNamedParameters, which defaults to true — so { id } binds to :id without writing the colon yourself.

The as unknown as Task[] casts are honest about reality: SQLite returns untyped rows, and pretending otherwise would be worse. In a larger system you would validate these rows the same way you validate incoming requests.

Step 5: Validation Without Decorators

Since class-validator and its decorator cousins are off the table, write validation as plain functions returning a result object. This is more code than a decorator, but it is code you can read and step through.

Create src/validate.ts:

import { Status } from "./types.ts";
import type { NewTask } from "./types.ts";
 
export type Validated<T> =
  | { ok: true; value: T }
  | { ok: false; errors: string[] };
 
const VALID_STATUSES = Object.values(Status) as string[];
 
export function validateNewTask(input: unknown): Validated<NewTask> {
  const errors: string[] = [];
 
  if (typeof input !== "object" || input === null) {
    return { ok: false, errors: ["body must be a JSON object"] };
  }
 
  const body = input as Record<string, unknown>;
 
  if (typeof body.title !== "string" || body.title.trim().length === 0) {
    errors.push("title is required and must be a non-empty string");
  } else if (body.title.length > 200) {
    errors.push("title must be 200 characters or fewer");
  }
 
  if (body.status !== undefined && !VALID_STATUSES.includes(String(body.status))) {
    errors.push(`status must be one of: ${VALID_STATUSES.join(", ")}`);
  }
 
  if (errors.length > 0) return { ok: false, errors };
 
  return {
    ok: true,
    value: {
      title: (body.title as string).trim(),
      status: body.status as NewTask["status"]
    }
  };
}
 
export function validateStatus(input: unknown): Validated<Status> {
  if (typeof input !== "object" || input === null) {
    return { ok: false, errors: ["body must be a JSON object"] };
  }
 
  const status = (input as Record<string, unknown>).status;
 
  if (!VALID_STATUSES.includes(String(status))) {
    return {
      ok: false,
      errors: [`status must be one of: ${VALID_STATUSES.join(", ")}`]
    };
  }
 
  return { ok: true, value: status as Status };
}

The Validated discriminated union means TypeScript narrows the type for you: inside an if (result.ok) branch, result.value is fully typed and result.errors does not exist.

Step 6: The HTTP Layer

Now the server itself, using only node:http. Create src/router.ts:

import type { IncomingMessage, ServerResponse } from "node:http";
import type { TaskStore } from "./store.ts";
import { validateNewTask, validateStatus } from "./validate.ts";
 
const MAX_BODY_BYTES = 64 * 1024;
 
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  let size = 0;
 
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY_BYTES) {
      throw new Error("request body too large");
    }
    chunks.push(chunk as Buffer);
  }
 
  if (chunks.length === 0) return {};
 
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
 
function send(res: ServerResponse, status: number, payload: unknown): void {
  const body = JSON.stringify(payload);
  res.writeHead(status, {
    "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(body)
  });
  res.end(body);
}
 
export function createRouter(store: TaskStore) {
  return async function handle(
    req: IncomingMessage,
    res: ServerResponse
  ): Promise<void> {
    const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
    const segments = url.pathname.split("/").filter(Boolean);
    const method = req.method ?? "GET";
 
    // GET /health
    if (method === "GET" && url.pathname === "/health") {
      return send(res, 200, { status: "ok", uptime: process.uptime() });
    }
 
    if (segments[0] !== "tasks") {
      return send(res, 404, { error: "not found" });
    }
 
    // GET /tasks
    if (method === "GET" && segments.length === 1) {
      return send(res, 200, { tasks: store.list() });
    }
 
    // POST /tasks
    if (method === "POST" && segments.length === 1) {
      const body = await readJsonBody(req);
      const result = validateNewTask(body);
 
      if (!result.ok) {
        return send(res, 422, { errors: result.errors });
      }
 
      return send(res, 201, { task: store.create(result.value) });
    }
 
    // Everything below needs a numeric :id
    const id = Number(segments[1]);
    if (segments.length !== 2 || !Number.isInteger(id) || id < 1) {
      return send(res, 400, { error: "invalid task id" });
    }
 
    // GET /tasks/:id
    if (method === "GET") {
      const task = store.get(id);
      return task
        ? send(res, 200, { task })
        : send(res, 404, { error: "task not found" });
    }
 
    // PATCH /tasks/:id
    if (method === "PATCH") {
      const result = validateStatus(await readJsonBody(req));
 
      if (!result.ok) {
        return send(res, 422, { errors: result.errors });
      }
 
      const task = store.updateStatus(id, result.value);
      return task
        ? send(res, 200, { task })
        : send(res, 404, { error: "task not found" });
    }
 
    // DELETE /tasks/:id
    if (method === "DELETE") {
      return store.remove(id)
        ? send(res, 204, {})
        : send(res, 404, { error: "task not found" });
    }
 
    return send(res, 405, { error: "method not allowed" });
  };
}

Now the entrypoint, src/server.ts:

import { createServer } from "node:http";
import { createTaskStore } from "./store.ts";
import { createRouter } from "./router.ts";
 
const PORT = Number(process.env.PORT ?? 3000);
const DB_PATH = process.env.DB_PATH ?? "./tasks.db";
 
const store = createTaskStore(DB_PATH);
const handle = createRouter(store);
 
const server = createServer((req, res) => {
  handle(req, res).catch((error: unknown) => {
    const message = error instanceof Error ? error.message : "unknown error";
    console.error("[request-error]", message);
 
    if (!res.headersSent) {
      res.writeHead(500, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "internal server error" }));
    }
  });
});
 
server.listen(PORT, () => {
  console.log(`[startup] listening on http://localhost:${PORT}`);
});
 
// Graceful shutdown — containers send SIGTERM before SIGKILL
function shutdown(signal: string): void {
  console.log(`[shutdown] received ${signal}, closing`);
  server.close(() => {
    store.close();
    process.exit(0);
  });
 
  // Force exit if connections refuse to drain
  setTimeout(() => process.exit(1), 10_000).unref();
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

The .catch() on the router is not optional. An unhandled rejection inside an async request handler will crash the process in modern Node, so every async boundary needs one.

Start it:

npm run dev
# [startup] listening on http://localhost:3000

--watch restarts on every save, replacing nodemon. Combined with type stripping, your edit-to-running-code loop has no build step at all.

Step 7: Configuration and Environment

Node reads .env files natively. Create one:

# .env
PORT=3000
DB_PATH=./tasks.db

The --env-file=.env flag in your scripts loads it — but it throws if the file is missing, which is the normal case in production where real environment variables are injected by the platform. Use the non-throwing variant there instead:

{
  "scripts": {
    "start": "node --env-file-if-exists=.env src/server.ts"
  }
}

If you prefer loading from code, process.loadEnvFile() does the same thing programmatically. It also throws on a missing file, so guard it:

// Only load a .env file in development; ignore it if absent
if (process.env.NODE_ENV !== "production") {
  try {
    process.loadEnvFile(".env");
  } catch {
    console.warn("[config] no .env file found, using process environment");
  }
}

For anything beyond a handful of variables, parse and validate config once at startup so a typo fails immediately rather than at 3 AM:

// src/config.ts
function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    console.error(`[config] missing required env var: ${key}`);
    process.exit(1);
  }
  return value;
}
 
export const config = {
  port: Number(process.env.PORT ?? 3000),
  dbPath: process.env.DB_PATH ?? "./tasks.db",
  nodeEnv: process.env.NODE_ENV ?? "development"
} as const;

Step 8: Testing With node --test

The built-in test runner handles .ts files with no extra configuration, because type stripping applies to test files too.

Create src/store.test.ts:

import { test, describe, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createTaskStore, type TaskStore } from "./store.ts";
 
describe("TaskStore", () => {
  let store: TaskStore;
 
  beforeEach(() => {
    // ":memory:" gives every test a clean, fast, isolated database
    store = createTaskStore(":memory:");
  });
 
  test("creates a task with a default status", () => {
    const task = store.create({ title: "Write the tutorial" });
 
    assert.equal(task.title, "Write the tutorial");
    assert.equal(task.status, "pending");
    assert.ok(task.id > 0);
  });
 
  test("lists tasks newest first", () => {
    store.create({ title: "first" });
    store.create({ title: "second" });
 
    const tasks = store.list();
    assert.equal(tasks.length, 2);
    assert.equal(tasks[0]?.title, "second");
  });
 
  test("updates status and returns the updated row", () => {
    const created = store.create({ title: "ship it" });
    const updated = store.updateStatus(created.id, "done");
 
    assert.equal(updated?.status, "done");
  });
 
  test("returns undefined when updating a missing task", () => {
    assert.equal(store.updateStatus(9999, "done"), undefined);
  });
 
  test("removes a task exactly once", () => {
    const created = store.create({ title: "temporary" });
 
    assert.equal(store.remove(created.id), true);
    assert.equal(store.remove(created.id), false);
  });
});

And src/validate.test.ts:

import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { validateNewTask } from "./validate.ts";
 
describe("validateNewTask", () => {
  test("accepts a valid payload and trims the title", () => {
    const result = validateNewTask({ title: "  padded  " });
 
    assert.equal(result.ok, true);
    if (result.ok) {
      assert.equal(result.value.title, "padded");
    }
  });
 
  test("rejects an empty title", () => {
    const result = validateNewTask({ title: "   " });
    assert.equal(result.ok, false);
  });
 
  test("rejects an unknown status", () => {
    const result = validateNewTask({ title: "ok", status: "nonsense" });
 
    assert.equal(result.ok, false);
    if (!result.ok) {
      assert.match(result.errors[0] ?? "", /status must be one of/);
    }
  });
 
  test("rejects a non-object body", () => {
    assert.equal(validateNewTask("a string").ok, false);
    assert.equal(validateNewTask(null).ok, false);
  });
});

Run them:

npm test

Useful variants:

node --test --watch 'src/**/*.test.ts'                  # rerun on save
node --test --experimental-test-coverage 'src/**/*.test.ts'  # coverage report
node --test --test-name-pattern="status" 'src/**/*.test.ts'  # filter by name

Wrap glob patterns in single quotes so your shell passes them to Node instead of expanding them itself.

Step 9: Type Checking as a CI Gate

This is the step people skip, and it is the one that matters most. Node runs your code without checking a single type. A typo in a property name ships straight to production unless something else catches it.

That something else is tsc --noEmit, promoted from build tool to mandatory gate:

npm run typecheck

Wire it into CI so it blocks merges:

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: "26"
          cache: "npm"
 
      - run: npm ci
 
      # Non-negotiable: nothing else checks types
      - name: Type check
        run: npm run typecheck
 
      - name: Test
        run: npm test

Add a local pre-commit hook if your team is prone to forgetting:

# .git/hooks/pre-commit
#!/bin/sh
npm run typecheck || {
  echo "Type check failed — commit aborted."
  exit 1
}

Remember that erasableSyntaxOnly in your tsconfig means this same gate also catches Rule 1 and Rule 5 violations. One command guards both type safety and runtime compatibility.

Step 10: Deploy With a Single-Stage Dockerfile

Here is where the payoff becomes visible. The conventional TypeScript Dockerfile is multi-stage: one stage installs everything and compiles, a second copies dist/ into a lean image. Without a build step, that entire dance disappears.

FROM node:26-slim
 
WORKDIR /app
 
# Install production dependencies only — TypeScript is not one of them
COPY package*.json ./
RUN npm ci --omit=dev
 
# Copy the TypeScript source; it IS the deployable artifact
COPY src ./src
 
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
 
# Run as a non-root user
USER node
 
CMD ["node", "src/server.ts"]

Read that again: npm ci --omit=dev skips TypeScript entirely, because production never needs the compiler. The image contains your source files and nothing else.

Build and run:

docker build -t task-api .
docker run -p 3000:3000 task-api

For a .dockerignore:

node_modules
*.db
.env
.git

If you deploy to a platform that runs npm start directly — Railway, Render, Fly.io — nothing changes. The start command already points at a .ts file. There is no build command to configure.

Testing Your Implementation

With the server running, walk through the full lifecycle:

# Health check
curl -s localhost:3000/health
 
# Create a task
curl -s -X POST localhost:3000/tasks \
  -H 'content-type: application/json' \
  -d '{"title":"Migrate the build pipeline"}'
# {"task":{"id":1,"title":"Migrate the build pipeline","status":"pending",...}}
 
# List
curl -s localhost:3000/tasks
 
# Update status
curl -s -X PATCH localhost:3000/tasks/1 \
  -H 'content-type: application/json' \
  -d '{"status":"done"}'
 
# Validation rejects bad input with 422
curl -s -X POST localhost:3000/tasks \
  -H 'content-type: application/json' \
  -d '{"title":""}'
# {"errors":["title is required and must be a non-empty string"]}
 
# Delete
curl -s -i -X DELETE localhost:3000/tasks/1
# HTTP/1.1 204 No Content

Then verify the safety net actually catches things. Introduce a deliberate type error:

const task = store.create({ title: 42 });  // number, not string

node src/server.ts runs this without complaint — the type is erased and SQLite stores it. npm run typecheck fails immediately. That gap between the two is exactly why Step 9 is mandatory.

Troubleshooting

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode — exactly what it says. Convert the enum to the as const pattern from Rule 1. You will see the same error class for parameter properties and namespaces with runtime members.

ERR_MODULE_NOT_FOUND pointing at a file that clearly exists — you omitted the .ts extension in a relative import (Rule 2), or you used a tsconfig path alias (Rule 4).

SyntaxError: The requested module does not provide an export named 'X' — you imported a type as a value. Add the type keyword (Rule 3). Turning on verbatimModuleSyntax prevents recurrences.

ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING — Node deliberately refuses to strip types inside node_modules. A dependency is shipping raw .ts. Report it upstream; published packages should ship JavaScript.

Decorator parse errors — no workaround exists under native stripping. Either drop the decorator-based library or keep a compile step for that project.

Types are fine locally but CI fails — check that CI runs Node 26. Older versions behave differently, and --experimental-transform-types no longer exists to paper over the difference.

Everything is slow under load — remember node:sqlite is synchronous. Profile your queries and add indexes; a missing index blocks the event loop on every request.

When You Should Not Do This

Being honest about the boundaries matters more than the sales pitch. Keep your build step if:

  • You use NestJS, TypeORM, or class-validator — all decorator-dependent, all incompatible
  • You render JSX/TSX — Next.js, Vite, and friends still need bundlers
  • You need tree-shaking or minification for cold-start-sensitive serverless functions
  • You must support Node 22 or older in any deployment target
  • Your codebase leans heavily on enums and namespaces and a migration is not worth the churn

Native stripping is ideal for backend services, CLIs, scripts, workers, and jobs — code where you control the syntax and value fast startup over bundle optimization.

Next Steps

Extend what you built:

  1. Add structured logging — swap console.log for a proper logger and emit JSON lines your platform can index
  2. Add rate limiting — see our guide on rate limiting with Upstash Redis
  3. Add observability — instrument with OpenTelemetry tracing
  4. Migrate to a real database — the store interface is small enough to swap for Drizzle ORM
  5. Compare compiler paths — read about TypeScript 7's Go-based compiler, which makes the type-check gate dramatically faster

Conclusion

Node.js 26 collapses the TypeScript backend toolchain to almost nothing. You built a complete REST API — routing, persistence, validation, tests, graceful shutdown, Docker deployment — with exactly one dev dependency and zero runtime dependencies. There is no dist/, no bundler config, and no gap between the code you wrote and the code that runs.

The trade is explicit and worth restating: you give up enums, decorators, parameter properties, path aliases, and JSX. In exchange you get instant startup, trivial deployment, and stack traces that point at real lines in real files.

The one discipline this demands is that tsc --noEmit becomes non-negotiable. Node will run anything you hand it. Your CI pipeline is now the only thing standing between a typo and production — set that gate up first, before you write the second file.