Skip to main content
TypeScript · lesson 19 of 21
TypeScript intermediate Lesson 19 of 21

Node.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.

Frequently Asked Questions

Do I need @types/node and @types/express?
Yes. Node.js and Express are JavaScript libraries without built-in TypeScript types. Install @types/node for Node.js globals (process, Buffer, etc.) and @types/express for Express types. On TypeScript 6 also list them in the types compiler option, which now defaults to an empty list.
How do I type request.body in Express?
Express types req.body as any by default. Use generics: Request<Params, ResBody, ReqBody, Query> to type it. For example, Request<{}, {}, CreateUserDto> gives you a typed body. The generic is only a claim, so validate the body at runtime too, for example with Zod.
How do I type environment variables?
process.env values are string | undefined. Create a typed config module that reads, validates, and exports environment variables as specific types. Never access process.env.ANYTHING directly in application code.
Does Express 5 catch errors thrown in async route handlers?
Yes. Express 5 forwards a rejected promise from a handler or middleware to the error-handling middleware, so an async handler can simply throw. Express 4 did not, and needed a wrapper or try/catch in every async handler to avoid unhandled rejections.
Should I run TypeScript in production with tsx or compile it first?
Compile with tsc and run the emitted JavaScript with node. That keeps type checking in the build, avoids a runtime transpiler dependency, and starts faster. tsx watch is convenient in development because it restarts on changes without a build step.