Node.js 24 TypeScript API without a build step
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.

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 write | What 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.

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-netis a "bad option" in 24.22.2, and under--permissionthe API above still listens on its port, andfetchstill reaches other servers. Node 26 adds--allow-net, as its permissions documentation shows. - The file-system limits cover
node:fs. Node's documentation namesnode:sqliteas 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-workeronly 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.
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.



