TypeScript with Node.js
TypeScript is a natural fit for server code, scripts and command-line tools on Node.js. This lesson sets up a Node project from scratch, uses Node's built-in modules with full types, builds a small typed HTTP server, and covers the ways to run TypeScript in development and production.
Project setup
mkdir notes-api && cd notes-api npm init -y npm install --save-dev typescript @types/node tsx npx tsc --init
@types/node provides types for everything Node ships with — fs, path, http, process and so on. tsx runs .ts files directly during development.
In package.json, opt into ES modules and add scripts:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit"
}
}
And a tsconfig.json that follows Node's module rules:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"types": ["node"],
"skipLibCheck": true,
"sourceMap": true
},
"include": ["src"]
}
The "types": ["node"] line matters: since TypeScript 6.0, global type packages aren't loaded unless listed, so without it names like process and Buffer would be unknown.
Using built-in modules
Node's APIs are fully typed, so you get autocomplete and checking for file system calls, paths and environment variables:
import { readFile, writeFile, mkdtemp } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; async function main() { const dir = await mkdtemp(join(tmpdir(), "ts-")); const file = join(dir, "notes.txt"); await writeFile(file, "first line\nsecond line\nthird line\n"); const text = await readFile(file, "utf8"); const lines = text.trim().split("\n"); console.log(`${lines.length} lines, longest: ${Math.max(...lines.map(l => l.length))} chars`); } main();
3 lines, longest: 11 chars
Because "utf8" was passed, TypeScript knows readFile returns a string rather than a Buffer — the Node type definitions use overloads (lesson 16) to model that.
Environment variables
process.env values are typed string | undefined, because any variable might be missing. Validate them once at startup:
function requireEnv(name: string, fallback?: string): string { const value = process.env[name] ?? fallback; if (value === undefined) { throw new Error(`Missing environment variable ${name}`); } return value; } export const config = { port: Number(requireEnv("PORT", "3000")), nodeEnv: requireEnv("NODE_ENV", "development"), }; console.log(config);
{ port: 3000, nodeEnv: 'development' }Every other file imports config and gets proper number and string types, instead of sprinkling process.env.X! everywhere.
A typed HTTP server
Node's built-in http module is enough to show the pattern. Frameworks such as Express, Fastify and Hono follow the same ideas with more convenience:
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; interface Note { id: number; text: string; } const notes: Note[] = [{ id: 1, text: "Learn TypeScript" }]; function sendJson(res: ServerResponse, status: number, body: unknown) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); } async function readBody(req: IncomingMessage): Promise<unknown> { let raw = ""; for await (const chunk of req) raw += chunk; return raw ? JSON.parse(raw) : undefined; } const server = createServer(async (req, res) => { if (req.method === "GET" && req.url === "/notes") { return sendJson(res, 200, notes); } if (req.method === "POST" && req.url === "/notes") { const body = await readBody(req); if (typeof body !== "object" || body === null || typeof (body as { text?: unknown }).text !== "string") { return sendJson(res, 400, { error: "text is required" }); } const note: Note = { id: notes.length + 1, text: (body as { text: string }).text }; notes.push(note); return sendJson(res, 201, note); } sendJson(res, 404, { error: "not found" }); }); server.listen(3000, () => console.log("listening on http://localhost:3000"));
Notice that the request body is treated as unknown and validated before use — the same principle as lesson 37. With Express you'd install @types/express and get typed req/res objects; Fastify and Hono ship their own types.
Running TypeScript on Node
| Approach | Command | Good for |
|---|---|---|
| Compile, then run JS | tsc && node dist/server.js | Production; fastest startup |
| tsx | tsx watch src/server.ts | Development with auto-restart |
| Node's built-in type stripping | node src/server.ts | Scripts and simple apps (Node 22.18+ / 23.6+) |
Node's built-in support only removes types; it doesn't check them, and it can't handle syntax that generates code — enums, namespaces and parameter properties. If you plan to rely on it, enable "erasableSyntaxOnly": true in tsconfig.json so the compiler flags those constructs. Whichever way you run code, keep tsc --noEmit in your workflow to catch type errors.
Common gotchas
- Import extensions. With
module: nodenextand"type": "module", relative imports need.js—import { config } from "./config.js"— even though the source file isconfig.ts. __dirnamein ES modules. It doesn't exist; useimport.meta.dirname(Node 20.11+).- "Cannot find name 'process'". Either
@types/nodeisn't installed, or it's missing from"types"intsconfig.json.