Skip to content

Contract-First API Design with OpenAPI and TypeScript

Stop letting your API spec drift from your implementation — define the contract first, generate types for both sides, and let the compiler catch the gaps.

Published on April 25, 20264 min read
OpenAPI YAML spec alongside generated TypeScript types showing contract-first API design

Most teams write the API first and document it later. The OpenAPI spec becomes a best-effort snapshot of reality — accurate at the time of writing, outdated by the next PR. Clients get burned. The spec becomes noise nobody trusts.

Contract-first development flips this: define the API shape in YAML before writing a single handler, then generate types from that contract. Both the server and client work against the same source of truth, and the compiler tells you when they've drifted.

Why Code-First Falls Apart

The drift is subtle at first. A field gets renamed in the handler but not in the spec. A response property becomes optional but the client assumes it's always present. A new query parameter shows up in code that nobody documented. After six months the spec is decorative.

tstypescript
// ❌ Code-first: spec and implementation evolve independently
// spec says: { userId: string, name: string }
// handler returns:
return {
  id: user.id,         // renamed — spec still says userId
  fullName: user.name, // renamed — spec still says name
  role: user.role,     // undocumented addition
};
 
// ✅ Contract-first: types derived from the spec — renaming the spec breaks the build
return {
  userId: user.id,
  name: user.name,
} satisfies paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];

The second version doesn't require discipline — it requires the compiler to enforce the contract.

Defining the Contract in OpenAPI

Start with the spec. Write it before any handler code. Keep it in the repository root where it's visible and version-controlled alongside the code it describes.

ymlyaml
# openapi.yaml
openapi: "3.1.0"
info:
  title: Users API
  version: "1.0.0"
paths:
  /users/{id}:
    get:
      operationId: getUserById
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: User found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
components:
  schemas:
    UserProfile:
      type: object
      required: [id, name, email, createdAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
          format: email
        createdAt:
          type: string
          format: date-time
    ErrorResponse:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
        message:
          type: string

This YAML is the canonical definition. Everything that follows derives from it — not the other way around.

Generating TypeScript Types from the Spec

openapi-typescript turns your YAML into a fully typed TypeScript module. No hand-written interfaces, no transcription errors.

shbash
# Install once
npm install -D openapi-typescript
 
# Regenerate after every spec change
npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts

The output is a paths type that mirrors your entire spec structure. Every route, every method, every request and response body is typed — including optional fields, discriminated unions, and deeply nested schemas.

~

Commit the generated api.d.ts to your repo and regenerate it in CI. A PR that changes the spec without regenerating types will fail the type check, catching drift before it merges.

Typing the Server Handler

With generated types, a handler's return type is derived from the spec rather than declared by hand. If the spec says a field is required and you omit it, the build breaks.

tstypescript
import type { paths } from "@/types/api";
import type { RequestHandler } from "express";
 
type GetUserResponse =
  paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];
 
type GetUserError =
  paths["/users/{id}"]["get"]["responses"]["404"]["content"]["application/json"];
 
export const getUserById: RequestHandler<{ id: string }> = async (req, res) => {
  const user = await userRepository.findById(req.params.id);
 
  if (!user) {
    const body: GetUserError = {
      code: "USER_NOT_FOUND",
      message: `No user with id ${req.params.id}`,
    };
    return res.status(404).json(body);
  }
 
  const body: GetUserResponse = {
    id: user.id,
    name: user.name,
    email: user.email,
    createdAt: user.createdAt.toISOString(),
  };
 
  res.status(200).json(body);
};

Rename name to fullName in the spec and this handler fails to compile until updated. That's the feedback loop code-first development can never give you.

Building a Type-Safe Client

The same generated paths type works on the client. No separate interface files, no manually kept UserProfile type that falls out of sync across packages.

tstypescript
import type { paths } from "@/types/api";
 
type UserProfile =
  paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];
 
async function getUser(id: string): Promise<UserProfile> {
  const response = await fetch(`/api/users/${id}`, {
    headers: { Accept: "application/json" },
  });
 
  if (response.status === 404) {
    throw new NotFoundError(`User ${id} does not exist`);
  }
 
  if (!response.ok) {
    throw new ApiError(`Unexpected status ${response.status}`);
  }
 
  return response.json() as Promise<UserProfile>;
}

For stricter guarantees, openapi-fetch wraps the native fetch API with full type checking on every parameter, header, and response — including which status codes are valid for a given operation.

Validating at Runtime

TypeScript types disappear at runtime. A client on a stale spec version, a misconfigured proxy, or an upstream bug can still deliver malformed data. Add runtime validation at the boundary.

tstypescript
import { z } from "zod";
 
// Mirror the OpenAPI schema with a Zod schema — or use openapi-zod-client to generate it
const userProfileSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});
 
export function parseUserProfile(raw: unknown) {
  const result = userProfileSchema.safeParse(raw);
 
  if (!result.success) {
    throw new ValidationError(
      "User profile response did not match contract",
      result.error.issues,
    );
  }
 
  return result.data;
}
i

Tools like openapi-zod-client and zod-openapi can generate Zod schemas directly from your OpenAPI spec, so runtime validation stays in sync with the contract without a manual translation step.

Enforcing the Workflow in CI

Contract-first only holds if the spec is always updated before the code. Enforce it structurally so it isn't a convention that erodes under deadline pressure.

ymlyaml
# .github/workflows/api-contract.yml
name: API Contract Check
on: [push, pull_request]
 
jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
 
      - name: Lint OpenAPI spec
        run: npx @redocly/cli lint openapi.yaml
 
      - name: Regenerate types
        run: npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts
 
      - name: Fail if types are out of sync
        run: |
          git diff --exit-code src/types/api.d.ts || \
          (echo "Regenerate types locally: npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts" && exit 1)
 
      - name: Type check
        run: npx tsc --noEmit

A PR that updates the spec without regenerating types fails at the diff check. A handler that breaks the generated contract fails at tsc --noEmit. Neither requires a reviewer to catch it — which means both get caught on every PR, not just the ones where someone remembers to look.

Key Takeaways

  1. Code-first specs drift by design — documentation written after the fact reflects intent, not reality. Define the contract before writing handlers.
  2. Generate, don't transcribe — hand-written TypeScript interfaces for API types are a maintenance liability. Derive them from one source of truth.
  3. Types are compile-time only — pair generated types with runtime Zod validation at trust boundaries; don't assume well-typed code is safe from malformed inputs.
  4. One spec, two consumers — the same paths type serves server handlers and client fetch wrappers, guaranteeing both sides speak the same language.
  5. Make drift impossible, not just forbidden — a CI pipeline that regenerates types and runs tsc removes the need for discipline by making non-compliance a build failure.
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX