TypeScript on the server Core
TypeScript on Node.js
Type Node.js with @types/node: typed env vars, files, HTTP, buffers, streams, events, child processes and signals, so the compiler catches server bugs early.
BY THE END OF THIS LESSON YOU CAN
- Explain where Node.js types come from and set up @types/node to match your runtime
- Turn process.env into a checked, typed and frozen config object instead of trusting string
- undefined
- Narrow the loose types Node.js hands you - request headers, fs errors, stream chunks, server addresses - without lying with as
- Type an EventEmitter's events and a pipeline of async generator stages
- Spawn a typed child process, handle its exit code and signal, and stop it cleanly with SIGTERM
The bug the compiler could have caught
A payments team moves its Node.js server from JavaScript to TypeScript. The first night in production, the server crashes at 2 a.m. The log says Cannot read properties of undefined (reading 'length'). The cause: someone told TypeScript that process.env.TOKEN_SECRET is always a string, the new server was started without it, and the first log-in request touched secret.length.
TypeScript did its job exactly as it was told. The problem is what it was told. Node.js hands your program many values whose real type is looser than it looks:
- every environment variable is a
string, or missing; readFilegives you bytes, not text, unless you ask for an encoding;- a request header can be missing, a single value, or several values joined into one string;
- a stream chunk is typed
any, whatever it really is; - an error thrown by
fscarries acodelike"ENOENT"that a plainErrordoes not have.
You used all of these APIs from JavaScript in Files, paths and your computer, Streams and buffers and Events, processes and workers. This lesson does not teach them again. It teaches their types: where they come from, what they promise, where they are too loose, and how to narrow them honestly. The BookStore API, which starts in the next lesson, is built from these same pieces: a checked config, guarded request bodies and typed errors.
Almost every example needs Node.js, so they are marked Node.js only. Run them with npx tsx file.ts in a folder set up as in Why TypeScript exists.
Where Node's types come from
Node.js is written in C++ and JavaScript. It ships no TypeScript types. The types live in a separate npm package, @types/node, written by volunteers in the DefinitelyTyped project. It is a set of declaration files (.d.ts): files that only describe what exists (functions, their parameters and return types) and contain no code that runs.
Without that package, TypeScript knows nothing about Node.js. Here is a project whose tsconfig.json says "types": [], "load no global type packages":
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": []
}
}
import { readFile } from "node:fs/promises";
const port = process.env["PORT"] ?? "3000";
console.log(port, await readFile("prices.json", "utf8"));
npx tsc --noEmit printsserver.ts:1:26 - error TS2591: Cannot find name 'node:fs/promises'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
1 import { readFile } from "node:fs/promises";
~~~~~~~~~~~~~~~~~~
server.ts:3:14 - error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
3 const port = process.env["PORT"] ?? "3000";
~~~~~~~
Found 2 errors in the same file, starting at: server.ts:1Both errors say the same thing: TypeScript has never heard of node:fs/promises or process. The error message even tells you the fix. Install the package as a development dependency, because types are only needed while you write and check code, never while it runs:
npm install -D typescript tsx @types/node@24And keep "types": ["node"] in tsconfig.json, as every project in this course does. Two details matter in real projects:
- Match the major version to your runtime.
@types/node@24describes Node.js 24. The newest@types/nodemay describe functions your Node.js does not have yet: the code type-checks and then crashes withis not a function. Pin the major version you deploy on. - Use the
node:prefix for built-in modules (node:fs,node:http). It makes clear the module is part of Node.js, and no npm package with the same name can take its place.
NOTE
The types describe Node.js; they do not check it. If a type in@types/node is wrong, the compiler believes the wrong type. This is rare for common APIs, but it is why you still test your code.Typed environment variables
In @types/node, process.env has the type NodeJS.ProcessEnv: an object where every key is string | undefined. That is the honest type. An environment variable is always text, and any of them can be missing:
const secret: string = process.env["TOKEN_SECRET"];
const port: number = process.env["PORT"];
npx tsc --noEmit printsenv-types.ts:1:7 - error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
1 const secret: string = process.env["TOKEN_SECRET"];
~~~~~~
env-types.ts:2:7 - error TS2322: Type 'string | undefined' is not assignable to type 'number'.
Type 'undefined' is not assignable to type 'number'.
2 const port: number = process.env["PORT"];
~~~~
Found 2 errors in the same file, starting at: env-types.ts:1The tempting fix that lies
You can make the errors disappear by declaration merging: adding your own properties to the ProcessEnv interface from @types/node. Many tutorials show this. Look at what it does:
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly TOKEN_SECRET: string;
}
}
}
const secret = process.env.TOKEN_SECRET;
try {
console.log("secret length:", secret.length);
} catch (error) {
console.log(error instanceof Error ? error.message : error);
}
npx tsx env-lie.tsCannot read properties of undefined (reading 'length')
It type-checks, and it crashes: this is the 2 a.m. bug from the start of the lesson. The declaration is a promise you made to the compiler, and nothing checks that the promise holds when the server starts. Types cannot check outside data (TypeScript and JavaScript together); only code that runs can.
Parse once, at startup
The honest solution: one function reads the raw strings, checks every one, and returns a typed, frozen object. It reports all problems at once, so an operator fixes the deployment in one go instead of restarting five times:
export type NodeEnv = "development" | "test" | "production";
export interface Config {
readonly nodeEnv: NodeEnv;
readonly port: number;
readonly tokenSecret: string;
readonly databaseUrl: string | undefined;
}
export type Env = Readonly<Record<string, string | undefined>>;
const NODE_ENVS: readonly NodeEnv[] = ["development", "test", "production"];
function isNodeEnv(value: string): value is NodeEnv {
return (NODE_ENVS as readonly string[]).includes(value);
}
export class ConfigError extends Error {
readonly problems: readonly string[];
constructor(problems: readonly string[]) {
super(`Invalid configuration:\n- ${problems.join("\n- ")}`);
this.name = "ConfigError";
this.problems = problems;
}
}
export function loadConfig(env: Env): Config {
const problems: string[] = [];
const rawNodeEnv = env["NODE_ENV"] ?? "development";
let nodeEnv: NodeEnv = "development";
if (isNodeEnv(rawNodeEnv)) nodeEnv = rawNodeEnv;
else problems.push(`NODE_ENV must be one of ${NODE_ENVS.join(", ")}, got "${rawNodeEnv}"`);
const rawPort = env["PORT"] ?? "3000";
const port = Number(rawPort);
if (!/^\d{1,5}$/.test(rawPort) || port > 65535) {
problems.push(`PORT must be a whole number from 0 to 65535, got "${rawPort}"`);
}
const tokenSecret = env["TOKEN_SECRET"] ?? "";
if (tokenSecret.length < 32) problems.push("TOKEN_SECRET must be at least 32 characters");
const databaseUrl = env["DATABASE_URL"];
if (nodeEnv === "production" && databaseUrl === undefined) {
problems.push("DATABASE_URL is required when NODE_ENV is production");
}
if (problems.length > 0) throw new ConfigError(problems);
return Object.freeze({ nodeEnv, port, tokenSecret, databaseUrl });
}
export function describeConfig(config: Config): string {
const db = config.databaseUrl === undefined ? "in-memory" : "set";
return `env=${config.nodeEnv} port=${config.port} secret=[${config.tokenSecret.length} chars] database=${db}`;
}
loadConfigtakes the environment as a parameter, typedEnv. The server passesprocess.env, which fits that type; a test passes a plain object.isNodeEnvis a type guard: afterif (isNodeEnv(rawNodeEnv)), TypeScript knows the string is one of the three literal values, sonodeEnvcan have the narrow typeNodeEnv. Later code canswitchon it and the compiler checks every case./^\d{1,5}$/refuses"80.5","-1"," 80"and"", whichNumber()would happily turn into numbers.describeConfigis what you log at startup. It never prints the secret itself, only its length. A config object is exactly the kind of thing someone logs "just to debug", and logs are read by many more people than your secrets should be.
Node.js 24 can read .env files itself: node --env-file=.env on the command line, or parseEnv from node:util in code. Both only produce strings; checking them is still your job. Try three environments:
import { parseEnv } from "node:util";
import { ConfigError, describeConfig, loadConfig } from "./src/config.js";
const files = [
"PORT=8080\nTOKEN_SECRET=0123456789abcdef0123456789abcdef\n",
"NODE_ENV=staging\nPORT=80.5\nTOKEN_SECRET=secret\n",
"NODE_ENV=production\nTOKEN_SECRET=0123456789abcdef0123456789abcdef\n",
];
for (const text of files) {
try {
console.log("started:", describeConfig(loadConfig(parseEnv(text))));
} catch (error) {
if (!(error instanceof ConfigError)) throw error;
console.log(error.message);
}
}
npx tsx try-config.tsstarted: env=development port=8080 secret=[32 chars] database=in-memory Invalid configuration: - NODE_ENV must be one of development, test, production, got "staging" - PORT must be a whole number from 0 to 65535, got "80.5" - TOKEN_SECRET must be at least 32 characters Invalid configuration: - DATABASE_URL is required when NODE_ENV is production
The second file has three problems, and all three are reported. The third is a production server with no database: better to refuse to start than to start with an empty in-memory database and lose every order at the next restart.
NEVER USE A REAL SECRET IN CODE
The 32-character values above are placeholders for the example. Real secrets come from the server's environment or a secret manager, are generated randomly (Cryptography with node:crypto), and are never committed.Files: overloads and error codes
readFile has several overloads: several signatures for one function, where the arguments decide the return type. Without an encoding you get a Buffer (bytes). With "utf8" you get a string:
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const dir = await mkdtemp(join(tmpdir(), "shop-"));
const file = join(dir, "price.txt");
await writeFile(file, "₦45,000");
const bytes = await readFile(file);
const text = await readFile(file, "utf8");
console.log(Buffer.isBuffer(bytes), bytes.length);
console.log(typeof text, text.length, text);
await rm(dir, { recursive: true });
npx tsx read-overloads.tstrue 9 string 7 ₦45,000
The same file is 9 bytes but 7 characters: ₦ takes 3 bytes in UTF-8 (Streams and buffers). If you forget the encoding and treat the result as text, the compiler stops you, because the overload returned a buffer type:
import { readFile } from "node:fs/promises";
const price = await readFile("price.txt");
console.log(price.toUpperCase());
npx tsc --noEmit printsread-mistake.ts:4:19 - error TS2339: Property 'toUpperCase' does not exist on type 'NonSharedBuffer'.
4 console.log(price.toUpperCase());
~~~~~~~~~~~
Found 1 error in read-mistake.ts:4NonSharedBuffer is how the current @types/node names a Buffer whose memory is a normal ArrayBuffer. In your editor, hover over price to see it.
Errors with a code
A missing file is often not an error at all: a first run has no saved cart yet. fs tells you why it failed with a code property: ENOENT (no such file), EACCES (no permission), EISDIR (it is a folder). But the catch variable is unknown (Typed error handling), and a plain Error has no code. @types/node has the right type, NodeJS.ErrnoException; you only need a guard that proves it:
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
export function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && typeof error.code === "string";
}
export async function readTextIfExists(path: string): Promise<string | undefined> {
try {
return await readFile(path, "utf8");
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") return undefined;
throw error;
}
}
export type Guard<T> = (value: unknown) => value is T;
export async function readJsonFile<T>(path: string, isT: Guard<T>): Promise<T | undefined> {
const text = await readTextIfExists(path);
if (text === undefined) return undefined;
const data: unknown = JSON.parse(text);
if (!isT(data)) throw new Error(`${basename(path)} does not have the expected shape`);
return data;
}
- Only
ENOENTbecomesundefined. Every other error is thrown again: a permission problem must not look like "no file yet", or your program would quietly start over with empty data. JSON.parsereturnsany, which would switch type checking off. Assigning it toconst data: unknownswitches it back on: nothing can usedatauntil the guard has proved its shape.readJsonFileis generic: the guard you pass decidesT, so the result is typed without a singleas.
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readJsonFile } from "./src/files.js";
interface StockLevel {
readonly sku: string;
readonly quantity: number;
}
function isStockList(value: unknown): value is StockLevel[] {
return Array.isArray(value) && value.every((item: unknown) =>
typeof item === "object" && item !== null
&& "sku" in item && typeof item.sku === "string"
&& "quantity" in item && Number.isInteger(item.quantity));
}
const dir = await mkdtemp(join(tmpdir(), "stock-"));
await writeFile(join(dir, "good.json"), '[{"sku":"RICE-5KG","quantity":12}]');
await writeFile(join(dir, "bad.json"), '[{"sku":"RICE-5KG","quantity":"twelve"}]');
for (const name of ["missing.json", "good.json", "bad.json"]) {
try {
const stock = await readJsonFile(join(dir, name), isStockList);
console.log(name, "->", stock === undefined ? "no file yet" : stock);
} catch (error) {
console.log(name, "->", error instanceof Error ? error.message : error);
}
}
await rm(dir, { recursive: true });
npx tsx try-files.tsmissing.json -> no file yet
good.json -> [ { sku: 'RICE-5KG', quantity: 12 } ]
bad.json -> bad.json does not have the expected shapeInside the loop, stock has the type StockLevel[] | undefined. The type came from the guard, and the guard really checked the data.
HTTP: what the types say about a request
REASON IT OUT
What can be missing in a request?
Your server gets an IncomingMessage for every request. Before looking at its types, think about where each part comes from and whether you can trust it:
- Is there always a method and a URL? Who wrote them?
- A client sends
Idempotency-Keytwice in one request. What shouldreq.headers["idempotency-key"]be? - What is the type of a body chunk, and how big can the whole body be?
- After
server.listen(0), what canserver.address()return?
Show the reasoning
The method and URL come from the client's first line of bytes. For a server, Node.js always fills them in, but IncomingMessage is also used for responses in the HTTP client, where they do not exist. So @types/node types them as string | undefined, and you need a fallback.
Headers are typed IncomingHttpHeaders: known headers have their own types, and any other name is string | string[] | undefined. For most headers, Node.js joins repeated values into one string, so two Idempotency-Key lines arrive in req.headers as "t-1, t-2", which looks like one odd key. Only a few headers (such as set-cookie) become arrays. And every header can be missing. When you need exactly one value, read req.headersDistinct: it keeps every value separately, typed NodeJS.Dict<string[]> (each name maps to string[] | undefined), so you can count them.
A body chunk from for await (const chunk of req) is typed any: streams can carry anything. For a request it is a Buffer, and the whole body can be as big as the client likes, so you count bytes and stop at a limit.
server.address() returns AddressInfo | string | null: an object for a TCP port, a string for a Unix socket path, and null before the server listens. Writing as AddressInfo hides two of those cases.
Here are small helpers that turn those loose types into safe ones. Each narrows with a check instead of an as:
import { once } from "node:events";
import type { IncomingMessage, Server, ServerResponse } from "node:http";
export class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message);
this.name = "HttpError";
}
}
export function singleHeader(req: IncomingMessage, name: string): string | undefined {
const values = req.headersDistinct[name];
return values?.length === 1 ? values[0] : undefined;
}
export async function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
if (!Buffer.isBuffer(chunk)) throw new TypeError("expected the body as bytes");
size += chunk.length;
if (size > maxBytes) throw new HttpError(413, `body is larger than ${maxBytes} bytes`);
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
export function sendJson(res: ServerResponse, status: number, body: unknown): void {
const text = JSON.stringify(body);
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(text) });
res.end(text);
}
export async function listenOnFreePort(server: Server): Promise<number> {
server.listen(0);
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string") throw new Error("expected a TCP address");
return address.port;
}
Buffer.isBuffer(chunk)is a type guard, so after itchunkis aBufferinstead ofany. If a stream ever sends something else, you get a clear error instead of nonsense.singleHeaderaccepts exactly one value. For a security-relevant header such as an idempotency key or a signature, two values are suspicious, and "missing" is the safe answer. Withreq.headersyou could not even tell that there were two.content-lengthusesBuffer.byteLength, nottext.length: a header counts bytes, and"₦"is one character but three bytes.
Now a tiny transfer endpoint. The parameters of the callback need no annotations: createServer's type tells TypeScript that req is an IncomingMessage and res a ServerResponse:
import { createServer } from "node:http";
import { HttpError, listenOnFreePort, readBody, sendJson, singleHeader } from "./src/http.js";
interface TransferRequest {
readonly toAccount: string;
readonly amountKobo: number;
}
function parseTransfer(text: string): TransferRequest {
const data: unknown = JSON.parse(text);
if (typeof data !== "object" || data === null) throw new HttpError(400, "send a JSON object");
if (!("toAccount" in data) || typeof data.toAccount !== "string" || !/^\d{10}$/.test(data.toAccount)) {
throw new HttpError(400, "toAccount must be a 10-digit account number");
}
if (!("amountKobo" in data) || !Number.isSafeInteger(data.amountKobo) || Number(data.amountKobo) <= 0) {
throw new HttpError(400, "amountKobo must be a positive whole number");
}
return { toAccount: data.toAccount, amountKobo: Number(data.amountKobo) };
}
const server = createServer(async (req, res) => {
const method = req.method ?? "GET";
const { pathname } = new URL(req.url ?? "/", "http://localhost");
try {
if (method !== "POST" || pathname !== "/transfers") throw new HttpError(404, "no such route");
const key = singleHeader(req, "idempotency-key");
if (key === undefined) throw new HttpError(400, "send exactly one Idempotency-Key header");
const transfer = parseTransfer(await readBody(req, 1_000));
sendJson(res, 202, { accepted: transfer, key });
} catch (error) {
if (error instanceof HttpError) sendJson(res, error.status, { error: error.message });
else if (error instanceof SyntaxError) sendJson(res, 400, { error: "body is not valid JSON" });
else sendJson(res, 500, { error: "internal error" });
}
});
const port = await listenOnFreePort(server);
async function post(headers: Record<string, string>, body: string): Promise<void> {
const response = await fetch(`http://localhost:${port}/transfers`, { method: "POST", headers, body });
console.log(response.status, await response.text());
}
await post({ "idempotency-key": "t-1001" }, '{"toAccount":"0123456789","amountKobo":1000000}');
await post({}, '{"toAccount":"0123456789","amountKobo":1000000}');
await post({ "idempotency-key": "t-1002" }, '{"toAccount":"12","amountKobo":-5}');
await post({ "idempotency-key": "t-1003" }, "{not json");
await post({ "idempotency-key": "t-1004" }, JSON.stringify({ toAccount: "0123456789", note: "x".repeat(2_000) }));
server.close();
npx tsx try-http.ts202 {"accepted":{"toAccount":"0123456789","amountKobo":1000000},"key":"t-1001"}
400 {"error":"send exactly one Idempotency-Key header"}
400 {"error":"toAccount must be a 10-digit account number"}
400 {"error":"body is not valid JSON"}
413 {"error":"body is larger than 1000 bytes"}parseTransfer uses the narrowing you learned in Narrowing: after "toAccount" in data and typeof data.toAccount === "string", TypeScript lets you read data.toAccount as a string. The oversized body stopped at 1,000 bytes with a 413. Real APIs check bodies with a schema library instead of by hand; Runtime validation shows how.
Buffers and Uint8Array
A Buffer is a Node.js subclass of the standard Uint8Array, the browser's type for raw bytes. So every function that asks for a Uint8Array accepts a Buffer. The other way round is where bugs hide: standard APIs such as TextEncoder, fetch's arrayBuffer() and Web Crypto give you plain Uint8Arrays, which do not have Buffer's methods. In JavaScript, this bug runs quietly:
const encoded = new TextEncoder().encode("₦500");
const wrong = (encoded as unknown as Buffer).toString("hex");
const right = Buffer.from(encoded).toString("hex");
console.log(encoded instanceof Buffer, encoded.length);
console.log("wrong:", wrong);
console.log("right:", right);
npx tsx bytes.tsfalse 6 wrong: 226,130,166,53,48,48 right: e282a6353030
Uint8Array.prototype.toString ignores its argument and joins the numbers with commas, so the "hex" string was not hex at all. It took a double cast, as unknown as Buffer, to get it past the compiler. Without that lie, TypeScript refuses the call:
const encoded = new TextEncoder().encode("₦500");
console.log(encoded.toString("hex"));
npx tsc --noEmit printsbytes-mistake.ts:2:30 - error TS2554: Expected 0 arguments, but got 1.
2 console.log(encoded.toString("hex"));
~~~~~
Found 1 error in bytes-mistake.ts:2The rule: accept Uint8Array in your own function parameters (it works for both), and call Buffer.from(bytes) when you need a Buffer method. Buffer.from on a Uint8Array copies the bytes; that is cheap for small values.
Streams with typed stages
A stream's chunks are typed any, because a stream can carry bytes, strings or objects. The cleanest way to get types back is pipeline from node:stream/promises with async generator functions as the stages. Each stage declares what it takes (AsyncIterable<string>) and what it produces (AsyncGenerator<OrderLine>), and TypeScript checks that each stage fits the next. You met generators in Generators and typed async iteration in Async TypeScript.
The job: a daily export of orders, one per line as id;customer;amountKobo, too big to load at once. Sum it per customer:
export interface OrderLine {
readonly id: number;
readonly customer: string;
readonly amountKobo: number;
}
export async function* toLines(chunks: AsyncIterable<string>): AsyncGenerator<string> {
let rest = "";
for await (const chunk of chunks) {
rest += chunk;
const lines = rest.split("\n");
rest = lines.pop() ?? "";
yield* lines;
}
if (rest !== "") yield rest;
}
export async function* toOrders(lines: AsyncIterable<string>): AsyncGenerator<OrderLine> {
for await (const line of lines) {
const [id, customer, amount] = line.split(";");
if (id === undefined || customer === undefined || amount === undefined) continue;
yield { id: Number(id), customer, amountKobo: Number(amount) };
}
}
export async function totalsByCustomer(orders: AsyncIterable<OrderLine>): Promise<Map<string, number>> {
const totals = new Map<string, number>();
for await (const order of orders) {
totals.set(order.customer, (totals.get(order.customer) ?? 0) + order.amountKobo);
}
return totals;
}
pipeline returns whatever the last stage returns, typed, here a Map<string, number>. The file is read in tiny 8-byte chunks on purpose, so you can see what chunking does:
import { createReadStream } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { toLines, toOrders, totalsByCustomer } from "./src/report.js";
const dir = await mkdtemp(join(tmpdir(), "report-"));
const file = join(dir, "orders.txt");
await writeFile(file, "1;Chịọma;1250000\n2;Ṣeun;300000\n3;Chịọma;450000\n4;Ṣeun;99900\n");
const naira = (kobo: number) => `₦${(kobo / 100).toFixed(2)}`;
const bytes = await pipeline(createReadStream(file, { highWaterMark: 8 }), toLines, toOrders, totalsByCustomer);
console.log("bytes: ", [...bytes].map(([name, kobo]) => `${name} ${naira(kobo)}`));
const text = await pipeline(createReadStream(file, { highWaterMark: 8, encoding: "utf8" }), toLines, toOrders, totalsByCustomer);
console.log("utf8: ", [...text].map(([name, kobo]) => `${name} ${naira(kobo)}`));
await rm(dir, { recursive: true });
npx tsx try-report.tsbytes: [ 'Chị���ma ₦12500.00', '���eun ₦3000.00', 'Chịọma ₦4500.00', 'Ṣeun ₦999.00' ] utf8: [ 'Chịọma ₦17000.00', 'Ṣeun ₦3999.00' ]
The first run is wrong, and the compiler did not notice. toLines says it takes strings, but a file stream without an encoding yields Buffers, and since chunks are any nothing checked the claim. rest += chunk then turned each 8-byte buffer into text on its own. Names like Chịọma and Ṣeun contain letters that take more than one byte, and wherever a chunk boundary cut one in half, each half became �, the replacement character. So one customer's orders were split across several broken "names".
With encoding: "utf8" the stream decodes across chunk boundaries and really yields strings. The lesson is about types as much as about bytes: at the edges where a type is any, your annotation is a claim, not a check. Make the claim true at the source (here, by asking the stream for strings), or check it (as readBody did with Buffer.isBuffer).
Typed events
EventEmitter accepts a type parameter: a map from each event name to the tuple of arguments it carries. With it, on knows the listener's parameters, and emit refuses a wrong name or a wrong payload:
import { EventEmitter } from "node:events";
interface Payment {
readonly orderId: number;
readonly amountKobo: number;
}
interface ShopEvents {
"payment.received": [payment: Payment];
"payment.refunded": [orderId: number, reason: string];
}
const shop = new EventEmitter<ShopEvents>();
shop.on("payment.received", (payment) => {
console.log(`stock: reserve items for order ${payment.orderId}`);
});
shop.on("payment.received", (payment) => {
console.log(`receipt: ₦${(payment.amountKobo / 100).toLocaleString("en-US")} for order ${payment.orderId}`);
});
shop.on("payment.refunded", (orderId, reason) => {
console.log(`refund: order ${orderId} (${reason})`);
});
shop.emit("payment.received", { orderId: 1001, amountKobo: 4_500_000 });
shop.emit("payment.refunded", 1001, "out of stock");
console.log("listeners:", shop.listenerCount("payment.received"));
npx tsx typed-events.tsstock: reserve items for order 1001 receipt: ₦45,000 for order 1001 refund: order 1001 (out of stock) listeners: 2
No listener has a type annotation: payment, orderId and reason all get their types from ShopEvents. The names in the tuple (payment:, orderId:) are only labels for your editor. Now two mistakes that JavaScript would let through until a customer's receipt said "₦NaN". Look closely at which one the compiler reports:
import { EventEmitter } from "node:events";
interface ShopEvents {
"payment.received": [payment: { orderId: number; amountKobo: number }];
}
const shop = new EventEmitter<ShopEvents>();
shop.emit("payment.recieved", { orderId: 1001, amountKobo: 4_500_000 });
shop.emit("payment.received", { orderId: 1001, amount: 45_000 });
npx tsc --noEmit printstyped-events-mistake.ts:9:48 - error TS2353: Object literal may only specify known properties, and 'amount' does not exist in type '{ orderId: number; amountKobo: number; }'.
9 shop.emit("payment.received", { orderId: 1001, amount: 45_000 });
~~~~~~
Found 1 error in typed-events-mistake.ts:9The wrong payload on line 9 is caught. The misspelt name on line 8, "payment.recieved", is not. That is on purpose in @types/node: an emitter may also carry events that are not in the map, and those get any[] arguments. So the most common event bug, an emit that goes nowhere because of a typo, still compiles. Always read what a type really promises before you rely on it.
A strict emitter
When you want every name checked, wrap the emitter in a small class whose methods only accept the keys of your map. #inner is a private field (Classes in TypeScript), so nobody can reach around the checks:
import { EventEmitter } from "node:events";
export class StrictEmitter<Events extends { [K in keyof Events]: unknown[] }> {
readonly #inner = new EventEmitter();
on<K extends keyof Events & string>(name: K, listener: (...args: Events[K]) => void): this {
this.#inner.on(name, listener);
return this;
}
emit<K extends keyof Events & string>(name: K, ...args: Events[K]): boolean {
return this.#inner.emit(name, ...args);
}
}
import { StrictEmitter } from "./src/strict-emitter.js";
interface ShopEvents {
"payment.received": [payment: { orderId: number; amountKobo: number }];
}
const shop = new StrictEmitter<ShopEvents>();
shop.on("payment.received", (payment) => console.log(payment.orderId));
shop.emit("payment.recieved", { orderId: 1001, amountKobo: 4_500_000 });
npx tsc --noEmit printsbus-mistake.ts:9:11 - error TS2345: Argument of type '"payment.recieved"' is not assignable to parameter of type '"payment.received"'.
9 shop.emit("payment.recieved", { orderId: 1001, amountKobo: 4_500_000 });
~~~~~~~~~~~~~~~~~~
Found 1 error in bus-mistake.ts:9Now the typo is a compile error, and the message names the event you meant. A type-safe event system builds a complete typed event bus on this idea.
Child processes and signals
The child_process types follow your options. promisify(execFile) gives you { stdout: string; stderr: string }, because the default encoding is UTF-8. When the program fails, the promise rejects with an error that also carries the exit code and the output. The catch variable is unknown, so you narrow it with a guard again:
import { execFile } from "node:child_process";
import type { ExecFileException } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
type ChildFailure = ExecFileException & { readonly stdout: string; readonly stderr: string };
function isChildFailure(error: unknown): error is ChildFailure {
return error instanceof Error && "stderr" in error && typeof error.stderr === "string";
}
const ok = await run(process.execPath, ["-e", "console.log(12500 * 3)"]);
console.log("stdout:", ok.stdout.trim());
try {
await run(process.execPath, ["-e", "console.error('printer offline'); process.exit(3)"]);
} catch (error) {
if (!isChildFailure(error)) throw error;
console.log("exit code:", error.code, "stderr:", error.stderr.trim());
}
npx tsx run-child.tsstdout: 37500 exit code: 3 stderr: printer offline
process.execPath is the path of the Node.js that is running your program, so the child uses the same version. The arguments go in an array, never glued into a command string: Events, processes and workers showed the shell injection that array prevents.
Signals are a union of names
A signal is a short message the operating system delivers to a process. SIGTERM means "please stop" (sent by kill, Docker and Kubernetes when they shut you down), SIGINT is Ctrl + C. In @types/node, NodeJS.Signals is a union of every valid name, so a typo is caught:
const SHUTDOWN_SIGNALS: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERN"];
for (const signal of SHUTDOWN_SIGNALS) {
process.once(signal, () => console.log(`stopping on ${signal}`));
}
npx tsc --noEmit printssignals-mistake.ts:1:64 - error TS2820: Type '"SIGTERN"' is not assignable to type 'Signals'. Did you mean '"SIGTERM"'?
1 const SHUTDOWN_SIGNALS: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERN"];
~~~~~~~~~
Found 1 error in signals-mistake.ts:1Now a real graceful stop between two typed processes. The worker pays out transfers. When it gets SIGTERM it takes no new work, finishes the payout in flight, and exits by itself:
const inFlight = new Set<number>([7]);
const keepAlive = setInterval(() => {}, 1_000);
process.once("SIGTERM", (signal: NodeJS.Signals) => {
console.log(`${signal}: no new payouts, finishing ${inFlight.size}`);
setTimeout(() => {
for (const id of inFlight) console.log(`payout ${id} sent`);
inFlight.clear();
clearInterval(keepAlive);
}, 100);
});
console.log("ready");
setInterval only keeps the process alive, as a real worker's open connections would. Once it is cleared and nothing else is pending, Node.js exits with code 0. The supervisor starts the worker, waits for ready, sends SIGTERM and reports how it ended:
import { spawn } from "node:child_process";
import { once } from "node:events";
import { createInterface } from "node:readline";
const worker = spawn(process.execPath, ["--import", "tsx", "payout-worker.ts"], {
stdio: ["ignore", "pipe", "inherit"],
});
createInterface({ input: worker.stdout }).on("line", (line) => {
console.log(`worker | ${line}`);
if (line === "ready") worker.kill("SIGTERM");
});
const [code, signal] = (await once(worker, "close")) as [number | null, NodeJS.Signals | null];
console.log("exit code:", code, "signal:", signal);
npx tsx supervise.tsworker | ready worker | SIGTERM: no new payouts, finishing 1 worker | payout 7 sent exit code: 0 signal: null
- Because of
stdio: ["ignore", "pipe", "inherit"], the type ofworker.stdoutisReadable, notReadable | null:@types/nodereads your options. With"inherit"in that position it would benull. --import tsxlets the child run a.tsfile through tsx, likenpx tsxdoes.once(worker, "close")returnsPromise<any[]>;node:eventscannot know the arguments of"close". Theashere states what the Node.js documentation promises: an exit code ornull, and a signal name ornull. Keep such casts at one place, with the documentation's guarantee behind them.- Exit code 0 and signal
nullmean the worker finished its payout and left on its own. Had it ignoredSIGTERM, the platform would eventually sendSIGKILL, and the result would be codenull, signal"SIGKILL": payout 7 cut off halfway.
Testing typed Node code
Because loadConfig takes the environment as a parameter, testing it needs no real environment variables at all. The test runner from Testing fundamentals runs TypeScript through tsx (tsx --test tests/*.test.ts, or node --import tsx --test):
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { ConfigError, loadConfig } from "../src/config.js";
const SECRET = "s".repeat(32);
describe("loadConfig", () => {
it("uses safe defaults for development", () => {
const config = loadConfig({ TOKEN_SECRET: SECRET });
assert.equal(config.port, 3000);
assert.equal(config.nodeEnv, "development");
assert.equal(Object.isFrozen(config), true);
});
it("reports every problem at once", () => {
assert.throws(() => loadConfig({ NODE_ENV: "prod", PORT: "-1" }), (error: unknown) => {
assert.ok(error instanceof ConfigError);
assert.equal(error.problems.length, 3);
return true;
});
});
it("requires a database in production", () => {
assert.throws(() => loadConfig({ NODE_ENV: "production", TOKEN_SECRET: SECRET }), /DATABASE_URL/);
});
});
npx tsx tests/config.test.ts▶ loadConfig ✔ uses safe defaults for development (1.430006ms) ✔ reports every problem at once (1.001718ms) ✔ requires a database in production (0.285304ms) ✔ loadConfig (4.647699ms) ℹ tests 3 ℹ suites 1 ℹ pass 3 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 91.923363
What to test in code like this: the defaults, each rule's failure, and the dangerous combinations (production without a database). The type checker covers the rest: config.port can only be a number, so no test needs to check that it is not a string.
Running TypeScript on a server
You have three ways to run the TypeScript you wrote on a server. They differ in what they check:
| Way | How | Checks types? |
|---|---|---|
| Compile first | tsc writes .js files, the server runs node dist/server.js | Yes, at build time |
| tsx | tsx src/server.ts strips types on the fly with esbuild | No |
| Node.js itself | node src/server.ts: Node.js 24 strips types by default | No |
Node.js 24 runs a .ts file directly by deleting the type annotations, without compiling anything. That only works for syntax that can simply be deleted. An enum or a constructor parameter property (constructor(readonly status: number)) generates JavaScript code, so plain Node.js refuses it. Here is the official Node.js 24 build running two files:
node src/price.ts ₦45,000 node src/status.ts ~/project/src/status.ts:1 > enum Status { Pending, Paid, > } SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode at parseTypeScript (node:internal/modules/typescript:68:40) … code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' } Node.js v24.19.0
If you plan to run with plain Node.js, turn on "erasableSyntaxOnly": true in tsconfig.json: then tsc reports enums and parameter properties while you write them, instead of Node.js at startup. (The HttpError class in this lesson uses a parameter property, so it runs with tsx but not with plain node.) Relative imports must then also name the real .ts file, which needs allowImportingTsExtensions or rewriteRelativeImportExtensions; tsconfig in depth explains both.
Whichever way you run, three rules keep production safe:
- tsx and Node.js never check types. Run
tsc --noEmitin CI on every pull request, and block the merge when it fails. - Pin
@types/nodeto your Node.js major version, and upgrade both together. - Parse every outside value at the edge: environment, files, requests, child output. Inside that edge your types are true; outside it they are hopes.
Practice
TRY IT YOURSELF
A typed upload limit
Add a setting MAX_UPLOAD_MB to a config loader: optional, default 10, a whole number from 1 to 100. The typed result should hold maxUploadBytes, already converted to bytes. Try the default, "25", "0" and "2.5".
Show a solution
type Env = Readonly<Record<string, string | undefined>>;
interface UploadConfig {
readonly maxUploadBytes: number;
}
function loadUploadConfig(env: Env): UploadConfig {
const raw = env["MAX_UPLOAD_MB"] ?? "10";
const mb = Number(raw);
if (!/^\d{1,3}$/.test(raw) || mb < 1 || mb > 100) {
throw new Error(`MAX_UPLOAD_MB must be a whole number from 1 to 100, got "${raw}"`);
}
return Object.freeze({ maxUploadBytes: mb * 1024 * 1024 });
}
for (const env of [{}, { MAX_UPLOAD_MB: "25" }, { MAX_UPLOAD_MB: "0" }, { MAX_UPLOAD_MB: "2.5" }]) {
try {
console.log(loadUploadConfig(env));
} catch (error) {
console.log(error instanceof Error ? error.message : error);
}
}
npx tsx upload-config.ts{ maxUploadBytes: 10485760 }
{ maxUploadBytes: 26214400 }
MAX_UPLOAD_MB must be a whole number from 1 to 100, got "0"
MAX_UPLOAD_MB must be a whole number from 1 to 100, got "2.5"Converting to bytes once, in the loader, means no other code ever multiplies by 1024 and gets it wrong. The name maxUploadBytes says the unit, so nobody compares it with a megabyte count.
TRY IT YOURSELF
Low-balance events
Write a BankAccount class that extends EventEmitter<AccountEvents>. withdraw(kobo) refuses to go below zero, and emits "low-balance" with the new balance when it drops under ₦5,000. Listen for it and withdraw twice from ₦12,000.
Show a solution
import { EventEmitter } from "node:events";
interface AccountEvents {
"low-balance": [balanceKobo: number];
}
class BankAccount extends EventEmitter<AccountEvents> {
constructor(private balanceKobo: number) {
super();
}
withdraw(kobo: number): number {
if (!Number.isSafeInteger(kobo) || kobo <= 0) throw new RangeError("amount must be a positive whole number of kobo");
if (kobo > this.balanceKobo) throw new RangeError("insufficient funds");
this.balanceKobo -= kobo;
if (this.balanceKobo < 500_000) this.emit("low-balance", this.balanceKobo);
return this.balanceKobo;
}
}
const account = new BankAccount(1_200_000);
account.on("low-balance", (balance) => console.log(`warning: only ₦${balance / 100} left`));
console.log("after ₦4,000:", account.withdraw(400_000) / 100);
console.log("after ₦5,000:", account.withdraw(500_000) / 100);
try {
account.withdraw(900_000);
} catch (error) {
console.log(error instanceof Error ? error.message : error);
}
npx tsx bank-events.tsafter ₦4,000: 8000 warning: only ₦3000 left after ₦5,000: 3000 insufficient funds
extends EventEmitter<AccountEvents> types this.emit inside the class and account.on outside it with the same map.
TRY IT YOURSELF
Create the file on first run
Using the ENOENT guard, write loadCart(path): when the file is missing, it writes an empty cart {"items":[]} and returns it; any other error is thrown. Call it twice on a fresh folder.
Show a solution
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
interface Cart {
readonly items: readonly { readonly sku: string; readonly quantity: number }[];
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && typeof error.code === "string";
}
async function loadCart(path: string): Promise<{ cart: Cart; created: boolean }> {
try {
const cart = JSON.parse(await readFile(path, "utf8")) as Cart;
return { cart, created: false };
} catch (error) {
if (!isErrnoException(error) || error.code !== "ENOENT") throw error;
const cart: Cart = { items: [] };
await writeFile(path, JSON.stringify(cart), { flag: "wx" });
return { cart, created: true };
}
}
const dir = await mkdtemp(join(tmpdir(), "cart-"));
console.log(await loadCart(join(dir, "cart.json")));
console.log(await loadCart(join(dir, "cart.json")));
await rm(dir, { recursive: true });
npx tsx cart-first-run.ts{ cart: { items: [] }, created: true }
{ cart: { items: [] }, created: false }The as Cart is acceptable only because this program wrote the file itself; for a file other people can edit, use a guard as readJsonFile does. flag: "wx" makes the write fail if another process created the file a moment earlier, instead of overwriting it.
Recap
- Node.js types come from
@types/node: declaration files you install with-D, enable with"types": ["node"], and pin to your Node.js major version. process.envvalues arestring | undefined. Declaration merging hides that without checking it; parse the environment once into a checked, frozenConfig, report every problem, and never log the secrets.- Overloads pick return types from arguments (
readFilewith and without an encoding).fserrors becomeNodeJS.ErrnoExceptionthrough a guard, and onlyENOENTmeans "no file yet". - Request parts are loose on purpose: method and URL may be
undefined, repeated headers are joined (useheadersDistinct), chunks areany,server.address()has three cases. Narrow each with a check. - Where a type is
any(stream chunks,events.once), your annotation is a claim. Make it true at the source or check it. EventEmitter<Events>types every listener and emit;NodeJS.Signalscatches signal typos;child_processtypes follow your options.- tsx and Node.js 24 strip types without checking them, so
tsc --noEmitruns in CI.
Next: BookStore API: HTTP and routing, a whole typed backend built from these pieces, with no framework.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.