API Design & Documentation

TypeScript Real World Applications — API Design & Documentation
TypeScript Real World Applications
Course 3 · Chapter 3 · API Design & Documentation

🌐 API Design & Documentation

An API's types are a promise to every client that consumes it — get them wrong or let them drift from reality, and every consumer inherits the bug. This chapter covers typing request/response shapes so they can't silently rot, generating documentation straight from those types, and versioning an API as it evolves.

REST Conventions, Typed

You've already seen REST verbs and resource routes (in the Express courses). The TypeScript-specific question is: what type does each route actually promise to return?

interface User { id: string; name: string; email: string; } // GET /users -> User[] // GET /users/:id -> User // POST /users -> User (body: CreateUserInput) // PATCH /users/:id -> User (body: Partial<CreateUserInput>) // DELETE /users/:id -> { deleted: true } type CreateUserInput = Omit<User, "id">;

Omit<T, K>

Builds an input type from an entity type by removing server-generated fields like id.

Partial<T>

Makes every field optional — exactly what a PATCH body needs versus a full PUT/POST body.

A Consistent Response Envelope

Wrapping every response in the same generic shape means clients can handle success and failure uniformly, and TypeScript can narrow on status:

type ApiResponse<T> = | { status: "success"; data: T; } | { status: "error"; message: string; code: number; }; function handle<T>(response: ApiResponse<T>) { if (response.status === "success") { console.log(response.data); // ✅ TypeScript knows `data` exists here } else { console.error(response.message); // ✅ and `message` exists here } }

This is the same discriminated-union pattern from Course 2 — status is the tag that lets the compiler narrow the rest of the shape.

🛡️ Types Vanish at Runtime

A request body is unknown until proven otherwise. Casting it to a type with as is a promise you haven't verified — a runtime validator is what actually keeps that promise:

Trusting a Cast vs Validating at the Boundary

Unsafe Cast
app.post("/users", (req, res) => { const input = req.body as CreateUserInput; // If the client sent garbage, this compiles fine // and crashes (or worse) somewhere downstream. });
Runtime-Validated
import { z } from "zod"; const CreateUserSchema = z.object({ name: z.string(), email: z.string().email(), }); app.post("/users", (req, res) => { const input = CreateUserSchema.parse(req.body); // input is now genuinely typed AND validated });

The payoff: CreateUserSchema can infer its own TypeScript type with z.infer<typeof CreateUserSchema> — one schema defines both the runtime check and the compile-time type, so they can never drift apart.

Generating Docs From Your Types

Hand-written Swagger YAML drifts from the code the moment someone changes a field and forgets the docs. Generating the spec from the same schemas you already validate with keeps them honest:

zod-to-openapi: One Source of Truth

import { extendZodWithOpenApi, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; import { z } from "zod"; extendZodWithOpenApi(z); const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), }).openapi("User"); const registry = new OpenAPIRegistry(); registry.registerPath({ method: "get", path: "/users/{id}", responses: { 200: { description: "A user", content: { "application/json": { schema: UserSchema } } }, }, }); // registry.definitions() generates the actual OpenAPI JSON/YAML document

Schema-First

zod/io-ts schemas generate both the TS type and the OpenAPI doc — one definition, no drift.

Decorator-Based (tsoa)

Annotate controller classes/methods; a build step reads the decorators and emits the OpenAPI spec.

🔢 Versioning Strategies

APIs change. The question is how to let old clients keep working while new clients get the new shape:

URL Versioning

/v1/users vs /v2/users — simplest to reason about, but duplicates routing.

Header Versioning

Accept: application/vnd.myapi.v2+json — keeps URLs stable, but harder to test in a browser.

At the type level, a discriminated union keeps each version's shape distinct instead of one type slowly accumulating optional fields for every version that ever existed:

type UserV1 = { version: 1; id: string; fullName: string; }; type UserV2 = { version: 2; id: string; firstName: string; lastName: string; }; type VersionedUser = UserV1 | UserV2; function getDisplayName(user: VersionedUser): string { return user.version === 1 ? user.fullName : `${user.firstName} ${user.lastName}`; // narrowed to UserV2 here }

💻 Coding Challenges

Challenge 1: Design the Response Envelope

Write an ApiResponse<T> discriminated union and a handleResponse function that narrows correctly on status for both the success and error branches.

Goal: Practice discriminated unions applied to API response shapes.

→ Solution

Challenge 2: Validate a Request Body

Using a zod-style schema (or a hand-rolled validator function), validate an incoming CreateProductInput body and derive its TypeScript type from the same schema.

Goal: Practice keeping runtime validation and compile-time types in sync from one source.

→ Solution

Challenge 3: Version a Type

Create ProductV1 and ProductV2 types with a version discriminant field, then write a function that accepts either and returns a normalized shape.

Goal: Practice discriminated unions for API versioning instead of one type full of optional fields.

→ Solution

⚠️ Gotcha: as Is Not Validation

Casting req.body as CreateUserInput tells the compiler to trust you — it does nothing at runtime. Anywhere a type crosses a real boundary (an HTTP request, a file read, a third-party API response), validate it with a real check, not a type assertion. The SQL Injection and XSS courses covered the same principle for a reason: never trust data you didn't produce yourself.

🎯 What's Next

Request and response shapes are now well-typed and documented — next up is Configuration Management: typing environment variables, validating config at startup instead of failing deep inside a request handler, and handling secrets safely.