Note: articles are for educational purposes and are not professional security advice — consult an expert for your specific needs.

Node.js 24 TypeScript API without a build step

In short

Node.js 24 runs TypeScript files directly through type stripping, stable since 24.12.0, and its Permission Model can restrict file access, child processes and worker threads. A notes API built only on Node built-ins runs under --permission and passes its tests on Node 24.22.2. Node never type-checks, so tsc --noEmit still belongs in CI, and Node 24 has no network permission; Node 26 adds --allow-net.

A terminal running node src/server.ts directly, with the Node.js 24 permission flag and erasableSyntaxOnly noted beside it

Node.js 24 TypeScript support needs no compiler: type stripping has been on by default since Node 23.6.0 and is stable from 24.12.0, so node src/server.ts runs with no build step, bundler or ts-node in between. Add the Permission Model and the built-in test runner, and a small API needs no runtime dependencies at all. This tutorial builds one, a notes API, and runs every file below on Node 24.22.2.

It matters because every dev tool you drop is one less package to update and audit. A typical TypeScript service carries ts-node or tsx, nodemon, dotenv and a test framework, and Node 24 now covers all four. The catch, which this guide keeps coming back to, is that Node strips types without ever checking them.

What Node.js 24 TypeScript support runs, and what it refuses

Node removes type annotations and runs what is left, which the Node.js 24 TypeScript documentation calls type stripping. Anything that needs TypeScript to generate JavaScript is refused. These are the results of running each case on Node 24.22.2:

TypeScript you writeWhat Node 24.22.2 does
Interfaces, type aliases and annotations.Strips them and runs the file, with no warning.
An enum.Stops with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: "TypeScript enum is not supported in strip-only mode".
Parameter properties, as in constructor(private x: number).Stops with the same error.
An import without the extension, "./helper".Fails with ERR_MODULE_NOT_FOUND; write "./helper.ts".
A .ts file inside node_modules.Refused by design, so libraries still ship JavaScript.

The --experimental-transform-types flag makes enums work in Node 24, with an experimental warning. Don't build on it: the Node.js 26 TypeScript documentation lists the flag as removed in 26.0.0. Write erasable TypeScript, and union types such as "admin" | "user" replace most enums.

Node.js 24 TypeScript at a glance: Write erasable TypeScript; Node strips types without ever checking them; Node 24 has no network permission; Run it with --permission-audit for a day

Step 1: Set up a project with no build step

The finished project, with a README, is on GitHub as trickyworld_notes_api; clone it to follow along, or build it file by file below.

The package.json holds the whole toolchain as scripts. TypeScript and the Node types are development dependencies for type-checking only; nothing ships to production but your .ts files:

{
  "name": "notes-api",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24.12"
  },
  "scripts": {
    "dev": "node --watch --env-file-if-exists=.env src/server.ts",
    "start": "node --permission --allow-fs-read=./src --allow-fs-read=./data --allow-fs-write=./data --env-file-if-exists=.env src/server.ts",
    "start:node26": "node --permission --allow-net --allow-fs-read=./src --allow-fs-read=./data --allow-fs-write=./data --env-file-if-exists=.env src/server.ts",
    "permission-audit": "node --permission-audit --allow-fs-read=./src --import ./src/audit.ts --env-file-if-exists=.env src/server.ts",
    "test": "node --test",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@types/node": "^24.19.2",
    "typescript": "^7.0.2"
  }
}

Each flag replaces a package: --watch replaces nodemon, --env-file-if-exists replaces dotenv, and node --test replaces a test framework. start:node26 is the same start command for Node 26, explained at the end.

The tsconfig.json follows the settings Node's documentation recommends, plus two lines. erasableSyntaxOnly makes tsc reject enums before Node does, and "types": ["node"] is needed with TypeScript 7:

{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "types": [
      "node"
    ]
  },
  "include": [
    "src",
    "test"
  ]
}

Without that types line, TypeScript 7.0.2 reports Cannot find name 'node:http' for every Node import, even with @types/node installed. Install the two packages, and once the files below exist, run the server straight from its source:

npm install -D typescript @types/node@24
node src/server.ts

No dist folder appears, because nothing is compiled.

Step 2: Define the types and validate input

Types vanish at runtime, so a request body is unknown until code checks it. This function returns either a valid note or the error message to send back:

export type Note = {
  id: string;
  title: string;
  body: string;
  createdAt: string;
};

export type NewNote = Pick<Note, "title" | "body">;

// The validated note, or a message to send back with a 400.
export function parseNewNote(input: unknown): NewNote | string {
  if (typeof input !== "object" || input === null) return "Send a JSON object";
  const { title, body } = input as Record<string, unknown>;
  if (typeof title !== "string" || !title.trim() || title.length > 120) {
    return "title must be 1 to 120 characters";
  }
  if (typeof body !== "string" || body.length > 5000) {
    return "body must be a string of up to 5000 characters";
  }
  return { title: title.trim(), body };
}

Limits on title and body are part of the security, not just tidiness: they cap what one request can store.

Step 3: Store notes without parameter properties

The store keeps notes in one JSON file and writes through a temporary file, so a crash never leaves half a file behind. Two details come from running TypeScript through Node. The constructor assigns a private field instead of using a parameter property, and a write queue makes saves run one at a time, so two requests arriving together can't overwrite each other's note:

import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Note } from "./notes.ts";

export class NoteStore {
  readonly #file: string;
  #queue: Promise<void> = Promise.resolve();

  // A plain field, not `constructor(private dir)`: type stripping can't run parameter properties.
  constructor(dir: string) {
    this.#file = join(dir, "notes.json");
  }

  async all(): Promise<Note[]> {
    try {
      return JSON.parse(await readFile(this.#file, "utf8")) as Note[];
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
      throw err;
    }
  }

  // Writes run one at a time, so two requests can't overwrite each other's note.
  add(note: Note): Promise<void> {
    const run = this.#queue.then(async () => {
      const notes = [...(await this.all()), note];
      await writeFile(`${this.#file}.tmp`, JSON.stringify(notes, null, 2));
      await rename(`${this.#file}.tmp`, this.#file);
    });
    this.#queue = run.catch(() => {});
    return run;
  }
}

That is the whole data layer.

Step 4: Read request bodies safely

The HTTP helpers enforce the rules every endpoint shares: JSON only, a 16 KB body limit, and headers that stop browsers guessing content types. The size check runs as each chunk arrives, so an oversized upload is refused with a 413 before it all sits in memory:

import type { IncomingMessage, ServerResponse } from "node:http";

const MAX_BODY_BYTES = 16 * 1024;

export class HttpError extends Error {
  readonly status: number;
  constructor(status: number, message: string) {
    super(message);
    this.status = status;
  }
}

export function send(res: ServerResponse, status: number, data: unknown): void {
  res.writeHead(status, {
    "content-type": "application/json; charset=utf-8",
    "x-content-type-options": "nosniff",
    "cache-control": "no-store",
  });
  res.end(JSON.stringify(data));
}

// Reads a JSON body, refusing anything over 16 KB before it is all in memory.
export async function readJson(req: IncomingMessage): Promise<unknown> {
  if (!req.headers["content-type"]?.startsWith("application/json")) {
    throw new HttpError(415, "Send Content-Type: application/json");
  }
  const chunks: Buffer[] = [];
  let size = 0;
  for await (const chunk of req as AsyncIterable<Buffer>) {
    size += chunk.length;
    if (size > MAX_BODY_BYTES) throw new HttpError(413, "Body too large");
    chunks.push(chunk);
  }
  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    throw new HttpError(400, "Invalid JSON");
  }
}

Every error becomes a clean status code.

Step 5: Write the note handlers

Each handler takes what it needs and returns a status and a body, which keeps it easy to test:

import type { IncomingMessage } from "node:http";
import { randomUUID } from "node:crypto";
import { readJson } from "./http.ts";
import { parseNewNote, type Note } from "./notes.ts";
import type { NoteStore } from "./store.ts";

export type Reply = { status: number; data: unknown };

export async function createNote(req: IncomingMessage, store: NoteStore): Promise<Reply> {
  const parsed = parseNewNote(await readJson(req));
  if (typeof parsed === "string") return { status: 400, data: { error: parsed } };
  const note: Note = { id: randomUUID(), ...parsed, createdAt: new Date().toISOString() };
  await store.add(note);
  return { status: 201, data: note };
}

export async function getNote(id: string, store: NoteStore): Promise<Reply> {
  const note = (await store.all()).find((n) => n.id === id);
  return note ? { status: 200, data: note } : { status: 404, data: { error: "Not found" } };
}

New notes get an ID from the built-in randomUUID.

Step 6: Route requests with URLPattern

URLPattern has been a global since Node 24.0.0, according to the Node.js 24.0.0 release notes, so routing needs no framework:

import { createServer } from "node:http";
import { HttpError, send } from "./http.ts";
import { createNote, getNote, type Reply } from "./handlers.ts";
import type { NoteStore } from "./store.ts";

const noteById = new URLPattern({ pathname: "/notes/:id" });

export function createApp(store: NoteStore) {
  return createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", "http://localhost");
    const id = noteById.exec(url)?.pathname.groups.id;
    try {
      let reply: Reply = { status: 404, data: { error: "Not found" } };
      if (url.pathname === "/notes" && req.method === "GET") reply = { status: 200, data: await store.all() };
      else if (url.pathname === "/notes" && req.method === "POST") reply = await createNote(req, store);
      else if (id && req.method === "GET") reply = await getNote(id, store);
      send(res, reply.status, reply.data);
    } catch (err) {
      if (err instanceof HttpError) return send(res, err.status, { error: err.message });
      console.error(err);
      send(res, 500, { error: "Internal error" });
    }
  });
}

Anything else gets a 404.

Step 7: Start the API under the Node.js permission model

The Permission Model, stable since Node 23.5.0, limits what the process may touch. With --permission on, file reads and writes, child processes and worker threads are denied unless a flag allows them. The start script grants reads on ./src and ./data and writes on ./data only.

The server checks its own permissions at startup:

import { createApp } from "./app.ts";
import { NoteStore } from "./store.ts";

const dataDir = process.env.DATA_DIR ?? "./data";
const port = Number(process.env.PORT ?? 3000);
const auditing = process.execArgv.includes("--permission-audit");

// Under --permission, stop at startup rather than on the first write.
if (process.permission && !auditing && !process.permission.has("fs.write", dataDir)) {
  console.error(`No write access to ${dataDir}: add --allow-fs-write=${dataDir}`);
  process.exit(1);
}

createApp(new NoteStore(dataDir)).listen(port, () => {
  console.log(`Notes API on http://localhost:${port}`);
});

Started without --allow-fs-write=./data, it exits with "No write access to ./data". That beats failing on the first POST in production. Any other file access throws ERR_ACCESS_DENIED, and the error names the scope, such as FileSystemRead, and the path.

Run it with npm start and try it:

curl -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"title":"Ship it","body":"No build step"}'

A valid note comes back with status 201 and its new id, an empty title gets a 400, and a 20 KB body gets a 413.

Step 8: Test with node:test

The test runner documentation says node --test also picks up *.test.ts files unless --no-strip-types is set. This test starts the server on a free port and uses the built-in fetch:

import { test } from "node:test";
import assert from "node:assert/strict";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AddressInfo } from "node:net";
import { createApp } from "../src/app.ts";
import { NoteStore } from "../src/store.ts";

test("creates a note, reads it back and rejects a bad one", async (t) => {
  const store = new NoteStore(await mkdtemp(join(tmpdir(), "notes-")));
  const server = createApp(store).listen(0);
  t.after(() => server.close());
  await once(server, "listening");
  const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
  const post = (body: unknown) => fetch(`${base}/notes`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });

  const created = await post({ title: "First note", body: "Hello" });
  assert.equal(created.status, 201);
  const note = await created.json();
  assert.deepEqual(await (await fetch(`${base}/notes/${note.id}`)).json(), note);

  assert.equal((await post({ title: "" })).status, 400);
});

Run it with npm test.

Step 9: Find the permissions an app needs

Guessing --allow-fs-read paths for an existing app is tedious. Node 24.20.0 added --permission-audit, which the command-line documentation describes as checking permissions without denying anything. Each would-be denial goes to a node:diagnostics_channel channel, so a small preload prints them:

// Logs every access the Permission Model would deny. Preload it with --import.
import { channel } from "node:diagnostics_channel";

type Denied = { permission: string; resource: string };

// "net" and "ffi" exist from Node 26; on Node 24 those two channels stay quiet.
for (const scope of ["fs", "net", "child", "worker", "inspector", "wasi", "addon", "ffi"]) {
  channel(`node:permission-model:${scope}`).subscribe((message) => {
    const { permission, resource } = message as Denied;
    console.warn(`[audit] would deny ${permission}: ${resource}`);
  });
}

The permission-audit script runs the API that way: node --permission-audit --allow-fs-read=./src --import ./src/audit.ts src/server.ts. Start it with npm run permission-audit, send one GET and one POST, and the log lists what it would have blocked (duplicates removed):

[audit] would deny FileSystemRead: data/notes.json
[audit] would deny FileSystemRead: data/notes.json.tmp
[audit] would deny FileSystemWrite: data/notes.json
[audit] would deny FileSystemWrite: data/notes.json.tmp

That list becomes the flags in the start script. I'd keep the audit preload in the repository for the next time the app's file access changes.

Where the permission model stops

Node's documentation is explicit that this is a "seat belt", not a sandbox: it stops trusted code from touching resources by mistake, and "does not protect against malicious code." Know its gaps before you rely on it:

  • Node 24 has no network permission. --allow-net is a "bad option" in 24.22.2, and under --permission the API above still listens on its port, and fetch still reaches other servers. Node 26 adds --allow-net, as its permissions documentation shows.
  • The file-system limits cover node:fs. Node's documentation names node:sqlite as a way around them, and in Node 24.22.2 it created a database file in a folder with no write grant.
  • Symbolic links are followed outside the granted paths, and existing file descriptors bypass the model.
  • The model doesn't carry over into worker threads, so grant --allow-worker only when you need it.

For untrusted code you still need a container or another operating-system boundary. I'd use the Permission Model as a second layer inside one.

Do you still need a build tool with Node.js 24?

For a Node.js 24 TypeScript service, not to run it. You still need tsc to check it. Node ran a file that assigned a number to a string field without a word, while tsc --noEmit stopped it with TS2322. I'd make npm run typecheck a required step in CI and treat Node's runtime as a fast loop, not a safety net.

A build step still earns its place in three cases. Libraries published to npm must ship JavaScript, because Node won't strip types in node_modules. Code that leans on enums, decorators or tsconfig path aliases needs TypeScript's compiler. And frontend code still needs a bundler, which our guide to how JavaScript bundlers work covers.

Plan your upgrade path too. Node 24 moved to maintenance on 20 October 2026 and reaches end of life on 30 April 2028, according to the Node.js release schedule. On Node 26, start the API with npm run start:node26: its Permission Model refuses even to listen on a port without --allow-net, still an experimental flag there, and npm start stops with ERR_ACCESS_DENIED. Type-checking and the test pass on Node 26.13.0 unchanged. If you're still on an older 24.x, our notes on Node.js 24.22.0 LTS cover the last feature release.

Start with one service: run it with --permission-audit for a day, turn the log into --allow-* flags, then switch to --permission.

FAQ

Frequently asked questions

Does Node.js 22 run TypeScript the same way?

Partly. Node 22.18.0 turned type stripping on by default and dropped the experimental warning, so the same erasable TypeScript runs there. Node's documentation marks the feature stable from 24.12.0 and 25.2.0, and --permission-audit arrived in Node 24.20.0.

Can I keep tsconfig path aliases with type stripping?

No. Node doesn't transform tsconfig "paths", so an aliased import fails. The closest built-in feature is subpath imports: entries under "imports" in package.json that start with #, which Node resolves itself.

How do I use the Permission Model with npx?

Pass it through npx: npx --node-options="--permission" package-name. Node needs to read the package itself, so add --allow-fs-read=$(npm prefix -g) for a globally installed package, as Node's permissions documentation shows.

Can permission flags live in a config file?

Yes, experimentally. A node.config.json file can hold them in a top-level "permission" object, such as "allow-fs-read": ["./src"]. You still pass --permission, and load the file with --experimental-default-config-file, which Node lists as early development.

Vishal

About the author

· Software & security engineer

GitHub · LinkedIn · X

Vishal writes Trickyworld's tutorials and interview-preparation guides on programming, databases, cyber security and AI. Vishal has been shipping production software long enough to have firm opinions about Python, PHP, Node.js, and JavaScript, and to have been wrong about each of them at least once. The work has run the whole stack: Drupal and Laravel applications, React front ends, MySQL and MongoDB underneath, Redis and Memcached for the things that had to be fast, Apache Solr for the things that had to be findable. That last part turned out to matter more than anyone expected. Tune a Solr index for a season, then watch a language model confidently retrieve the wrong document, and you recognise an old problem wearing new clothes. Most of what is hard about AI systems is not the model. It is retrieval, caching, latency budgets, and deciding what the system does when the answer comes back wrong — which is work he has been doing since well before anyone called it AI engineering. He plays a lot of games, which is less a hobby disclosure than an admission of where a good share of his engineering instinct came from. Real-time systems with a frame budget. State that has to stay coherent while a player does something no designer anticipated. The permanent question of what happens when the frame cannot finish in time. Those habits carry over more directly than they have any right to. He has written several books on programming, all built on the same conviction: most technical writing is unclear because the author never decided what to leave out. More from Vishal → · Editorial policy

#TypeScript#API Security#permissions#testing#Node.js

More on NodeJS.

JavaScript

Project Showcase in Web Development: Build Engaging Apps

Discover the process of building a real-world web application, from planning and design to development, testing, and deployment. This article guides you through the stages of creating a project showcase that reflects your skills and creativity in web development.

2 min read

Latest articles

$ subscribe --weekly

Get the next tutorial in your inbox.

Programming, security and AI — the practical bits, no spam. Unsubscribe any time.