<< Home

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);
  }
});
💡 Fastify’s schema system doubles as documentation. When you keep schemas close to handlers, both the runtime and the reader benefit.

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;
⚠️ Validators can become a performance bottleneck if they grow very large. Keep schemas focused and avoid deeply nested structures unless your use case demands them.

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" }
      }
    }
  }
}
💡 When your OpenAPI spec is always produced from your types, you gain a single source of truth. This makes cross team collaboration calmer and more predictable.

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 };
}
⚠️ Avoid over designing your error model. A few well chosen variants often work better than sprawling hierarchies that slow development and confuse consumers.