Chapter 24: APIs with Confidence
Robust APIs thrive on clarity. With TypeScript in Node you can express request shapes, response contracts, and error conditions in a way that helps both humans and machines understand the boundaries of your service. This chapter looks at how to apply type safety to common HTTP frameworks, how to validate data at runtime, how to generate OpenAPI specifications from your definitions, and how to model errors in a clean and predictable way.
Type safe HTTP with Fastify or Express
Fastify and Express let you shape web servers with minimal code, although their approaches differ. Fastify includes built in schema features that align naturally with TypeScript, while Express keeps things open and flexible. Both can become type safe once you pair them with strongly typed handlers and clear request contracts.
Fastify built in schema support
Fastify encourages you to define JSON schemas for requests and responses. When paired with TypeScript, those schemas become hints that guide your handlers toward safer code. You register a route with a schema property, then TypeScript infers parameter and body types whenever possible.
fastify.route({
method: "POST",
url: "/items",
schema: {
body: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"]
}
},
handler: async (req, reply) => {
const item = { id: 1, name: req.body.name };
return reply.send(item);
}
});
Type safe Express handlers
Express does not enforce request shapes, so you supply them manually with generics. This works well for smaller services where you do not need a full schema driven approach. Define interfaces for query strings, parameters, bodies, and responses, then attach them to your handlers.
import { Request, Response } from "express";
interface Body {
title: string;
}
function create(req: Request, res: Response) {
res.json({ ok: true, title: req.body.title });
}
Runtime validation with Zod or similar
Compile time checks catch many mistakes, but they cannot protect you from malformed data arriving over the network. Runtime validators such as Zod let you enforce contracts at the boundary, which keeps your API honest and prevents silent failures.
Defining a schema once and using it twice
A Zod schema describes the allowed shape of a payload. TypeScript infers the static type from that schema, so you avoid writing the same structure twice. When requests arrive, you parse the input through the schema and get a safe value or a validation error.
import { z } from "zod";
const Item = z.object({
name: z.string(),
price: z.number().positive()
});
type Item = z.infer<typeof Item>;
Integrating validation into handlers
The validator becomes a small guard at the top of each route. Failed validations produce clear error messages that callers can rely on. Successful validations give you strong types for the rest of the handler.
const parsed = Item.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ error: parsed.error.format() });
}
const item = parsed.data;
OpenAPI generation from types
OpenAPI documents describe your service in a machine readable format. Tools that link TypeScript types to OpenAPI let you create this documentation automatically. This avoids drift between code and documentation and gives client generators a reliable roadmap.
Schema driven generators
Libraries such as openapi-typescript, ts-json-schema-generator, or framework specific plugins read your types and produce JSON schema or OpenAPI definitions. These artifacts become inputs for documentation servers, test suites, and client stubs.
npx openapi-typescript schema.json --output types.ts
Embedding OpenAPI metadata in routes
Some frameworks let you embed OpenAPI metadata directly inside route definitions. This approach keeps documentation close to your implementation and reduces the chance of mismatches.
{
method: "GET",
url: "/items",
schema: {
response: {
200: {
type: "array",
items: { type: "string" }
}
}
}
}
Error modeling and typed results
APIs need a calm and structured way to communicate failure. Typed errors turn vague exceptions into clear signals about what went wrong and how clients should respond. Instead of throwing unstructured messages, define a small set of error shapes and return them consistently.
Defining result types
A discriminated union separates successful paths from failures. Each failure case carries enough context to help debugging while staying small enough to serialize safely. Clients can inspect the tag field to decide on the right action.
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: "NotFound" | "InvalidInput" | "Conflict"; detail?: string };
Returning typed errors in handlers
Your handler returns either a successful result or a typed failure. This makes error handling explicit and gives upstream callers a pattern that is simple to test.
function getItem(id: string): Result<string> {
if (id === "") {
return { ok: false, error: "InvalidInput" };
}
return { ok: true, value: "Item " + id };
}