TypeScript · lesson 19 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Node.js with TypeScript
Build a typed Express 5 API: tsconfig for Node, validated env config, typed routes and middleware, async errors and Zod validation, with real curl output.
By SkillByExample · Updated
This lesson builds a small users API in an api/ directory. Versions used for every output below: Node 22.22, TypeScript 6.0.3, Express 5.2.1, @types/express 5.0.6, @types/node 22.20, and Zod 4.6.
Project Setup
Install the runtime and type dependencies, then give the compiler a Node-shaped config:
npm install express zod
npm install -D typescript tsx @types/node @types/express
{
"compilerOptions": {
"module": "nodenext",
"target": "es2023",
"rootDir": "src",
"outDir": "dist",
"types": ["node"],
"verbatimModuleSyntax": true,
"sourceMap": true
},
"include": ["src"]
}
module: nodenext makes the compiler follow Node’s own ES module rules (relative imports end in .js). types: ["node"] matters on TypeScript 6, which no longer loads installed @types packages automatically. strict isn’t listed because TypeScript 6 turns it on by default. The package.json has "type": "module" and these scripts:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p .",
"start": "node --enable-source-maps dist/server.js"
}
}
dev restarts on every save without type-checking; build is where types are checked; start runs the compiled JavaScript.
Typed Environment Variables
Every process.env value is string | undefined. Read and validate them once, in one module, and export typed values.
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`missing environment variable ${name}`);
return value;
}
function int(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isInteger(n)) throw new Error(`${name} must be an integer, got "${raw}"`);
return n;
}
export const config = {
port: int("PORT", 3000),
apiKey: required("API_KEY"),
logRequests: process.env.LOG_REQUESTS === "true",
} as const;
import { config } from "./config.js";
console.log(config);
$ npx tsc -p api
$ node api/dist/show-config.js 2>&1 | grep -m1 '^Error'
Error: missing environment variable API_KEY
$ PORT=abc API_KEY=k node api/dist/show-config.js 2>&1 | grep -m1 '^Error'
Error: PORT must be an integer, got "abc"
$ PORT=4500 API_KEY=secret node api/dist/show-config.js
{ port: 4500, apiKey: 'secret', logRequests: false }
A misconfigured deploy fails at startup with a clear message, rather than on the first request that reads the setting. Everywhere else, config.port is a number and config.logRequests a boolean — no code downstream parses strings.
Typed Routes
Request<Params, ResBody, ReqBody, Query> and Response<ResBody> carry the types of each part of a request.
export interface User { id: number; name: string; email: string }
const users: User[] = [{ id: 1, name: "Alice", email: "alice@example.com" }];
export const userStore = {
find: (id: number) => users.find((u) => u.id === id),
list: (limit: number) => users.slice(0, limit),
create(data: Omit<User, "id">): User {
const user = { id: users.length + 1, ...data };
users.push(user);
return user;
},
};
import { Router, type Request, type Response } from "express";
import { userStore, type User } from "./users.js";
import { NotFoundError } from "./errors.js";
import { validate, CreateUserSchema, type CreateUserDto } from "./validate.js";
export const router = Router();
router.get("/users", (req: Request<{}, User[], {}, { limit?: string }>, res: Response<User[]>) => {
res.json(userStore.list(Number(req.query.limit ?? 10)));
});
router.get("/users/:id", async (req: Request<{ id: string }>, res: Response<User>) => {
const user = userStore.find(Number(req.params.id));
if (!user) throw new NotFoundError("user", req.params.id);
res.json(user);
});
router.post("/users", validate(CreateUserSchema), (req: Request<{}, User, CreateUserDto>, res: Response<User>) => {
res.status(201).json(userStore.create(req.body));
});
req.params.id is a string — route parameters always are — so it’s converted explicitly. Response<User> means res.json({ oops: true }) would be a compile error. The /users/:id handler is async and simply throws: Express 5 forwards a rejected promise to the error handler, which Express 4 did not.
Typed Errors and Middleware
Error classes carry an HTTP status; one error-handling middleware turns them into responses.
import type { ErrorRequestHandler } from "express";
export class HttpError extends Error {
constructor(readonly status: number, message: string, readonly details?: unknown) {
super(message);
}
}
export class NotFoundError extends HttpError {
constructor(resource: string, id: string) {
super(404, `${resource} ${id} not found`);
}
}
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const status = err instanceof HttpError ? err.status : 500;
const message = err instanceof HttpError ? err.message : "internal error";
if (status === 500) console.error(`[${req.requestId}]`, err);
res.status(status).json({ error: message, details: err instanceof HttpError ? err.details : undefined, requestId: req.requestId });
};
req.requestId isn’t part of Express’s Request type. A middleware adds it, and a declaration-merging block teaches the compiler about it:
import type { RequestHandler } from "express";
import { randomUUID } from "node:crypto";
declare global {
namespace Express {
interface Request {
requestId: string;
}
}
}
export const requestId: RequestHandler = (req, res, next) => {
req.requestId = req.get("x-request-id") ?? randomUUID().slice(0, 8);
res.setHeader("x-request-id", req.requestId);
next();
};
The augmentation applies to every Request in the program, so it’s declared in exactly one place — the module that sets the value.
Validation with Zod
A Zod schema is a runtime validator and the source of a static type, so the two can’t drift apart.
import { z } from "zod";
import type { RequestHandler } from "express";
import { HttpError } from "./errors.js";
export const CreateUserSchema = z.object({
name: z.string().trim().min(2),
email: z.email(),
});
export type CreateUserDto = z.infer<typeof CreateUserSchema>;
export function validate(schema: z.ZodType): RequestHandler {
return (req, _res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) throw new HttpError(422, "validation failed", z.flattenError(result.error).fieldErrors);
req.body = result.data;
next();
};
}
z.infer derives { name: string; email: string } from the schema. After validate runs, req.body really has that shape — and trim() has already been applied to name.
Wiring It Together
import express from "express";
import { config } from "./config.js";
import { requestId } from "./request-id.js";
import { router } from "./routes.js";
import { errorHandler } from "./errors.js";
const app = express();
app.use(express.json());
app.use(requestId);
app.use(router);
app.use(errorHandler);
app.listen(config.port, () => console.log(`listening on http://localhost:${config.port}`));
$ npx tsc -p api
$ curl -s localhost:4500/users/1
{"id":1,"name":"Alice","email":"alice@example.com"}
$ curl -s -H 'x-request-id: req-42' localhost:4500/users/99
{"error":"user 99 not found","requestId":"req-42"}
$ curl -s -X POST localhost:4500/users -H 'content-type: application/json' -d '{"name":" Bob ","email":"bob@example.com"}'
{"id":2,"name":"Bob","email":"bob@example.com"}
$ curl -s -X POST localhost:4500/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
{"error":"validation failed","details":{"name":["Too small: expected string to have >=2 characters"],"email":["Invalid email address"]},"requestId":"d4758cdc"}
$ curl -s 'localhost:4500/users?limit=5'
[{"id":1,"name":"Alice","email":"alice@example.com"},{"id":2,"name":"Bob","email":"bob@example.com"}]
The thrown NotFoundError from the async handler reached errorHandler and became a 404 body carrying the request ID we sent. " Bob " was stored as "Bob", because the handler received Zod’s parsed output rather than the raw body. The failed validation lists every bad field.
The compiler also guards the handlers. Here’s a mistake in a response body:
import { Router, type Request, type Response } from "express";
import type { User } from "./users.js";
export const broken = Router();
broken.get("/me", (req: Request, res: Response<User>) => {
res.json({ id: 1, name: "Alice" });
console.log(req.requestId.toUpperCase(), req.params.id);
});
$ npx tsc -p api
api/src/broken.ts(7,12): error TS2345: Argument of type '{ id: number; name: string; }' is not assignable to parameter of type 'User'.
Property 'email' is missing in type '{ id: number; name: string; }' but required in type 'User'.
The response is missing email. The req.requestId line compiles because of the global augmentation, and req.params.id compiles because a plain Request has loosely typed params — which is why the typed routes above spell out Request<{ id: string }>.
Practice
1. Add a GET /users/:id/email route whose Response type is { email: string }, and try returning the whole user. What does tsc say?
Solution
import { Router, type Request, type Response } from "express";
import { userStore } from "./users.js";
import { NotFoundError } from "./errors.js";
export const emailRouter = Router();
emailRouter.get("/users/:id/email", (req: Request<{ id: string }>, res: Response<{ email: string }>) => {
const user = userStore.find(Number(req.params.id));
if (!user) throw new NotFoundError("user", req.params.id);
res.json({ email: user.email, name: user.name });
});
$ npx tsc -p api
api/src/email.ts(10,33): error TS2353: Object literal may only specify known properties, and 'name' does not exist in type '{ email: string; }'.
The object literal has an extra name, which the excess property check rejects. Passing user itself would compile — structural typing allows extra properties on a non-literal — and would leak every field, so build the response object explicitly.
2. Add an optional age to CreateUserSchema that must be an integer from 13 to 120. What type does z.infer give it?
Solution
import { z } from "zod";
const Schema = z.object({ name: z.string().min(2), age: z.number().int().min(13).max(120).optional() });
type Dto = z.infer<typeof Schema>;
const probe: Dto = { name: "Ada", age: "36" };
for (const body of [{ name: "Ada" }, { name: "Ada", age: 36 }, { name: "Ada", age: 7.5 }]) {
const r = Schema.safeParse(body);
console.log(JSON.stringify(body), r.success ? "ok" : z.flattenError(r.error).fieldErrors);
}
$ npx tsc -p api
api/src/age.ts(6,35): error TS2322: Type 'string' is not assignable to type 'number'.
$ npx tsx api/src/age.ts
{"name":"Ada"} ok
{"name":"Ada","age":36} ok
{"name":"Ada","age":7.5} { age: [ 'Invalid input: expected int, received number' ] }
age is inferred as number | undefined, so the string "36" is a compile error. At runtime 7.5 is rejected by the integer check. fieldErrors groups messages by field, which is the shape an API client can map straight onto form inputs.
3. Start the compiled server without API_KEY. Why is this better than failing when a route first reads the key?
Solution
$ PORT=4501 node api/dist/server.js 2>&1 | grep -m1 '^Error'
Error: missing environment variable API_KEY
The process exits during startup, before it listens on a port. A deploy or health check sees the failure immediately, instead of a server that looks healthy and fails on the first request that needs the key.