Skip to content

Web Development · API Design

OpenAPI-First API Development: Write the Contract Before the Code

Code-first API development produces documentation as an afterthought. OpenAPI-first flips that: you write the spec, generate the server stubs and client SDKs, and enforce the contract at every layer. Here's how it works in practice.

Anurag Verma

Anurag Verma

6 min read

OpenAPI-First API Development: Write the Contract Before the Code

Sponsored

Share

Most teams write APIs the same way: build the endpoints, wire up the handlers, then generate docs from the running server. OpenAPI comes last, as a description of what was already built. The problem with this order is that the spec becomes a byproduct: accurate when it’s first generated, gradually wrong as the API evolves, rarely updated after the initial release.

OpenAPI-first reverses that. You write the spec first. The spec is the source of truth. Code is generated from the spec, validated against the spec, and clients are distributed as generated SDKs from the spec. Documentation is always correct because it’s the input, not the output.

This isn’t a new idea, but the tooling has matured enough in 2026 that it’s practical for projects of any size.

What You Get From a Good Spec

An OpenAPI 3.1 spec is a YAML or JSON document that describes every endpoint: its path, method, parameters, request body, possible responses, and the schemas for each. When it’s your source of truth, several things follow automatically:

Server-side validation: Requests that don’t match the spec can be rejected before they reach your handler. No manual if (!req.body.email) guards.

Generated client SDKs: Front-end teams get a typed client that matches the API exactly. No hunting through documentation to find the right parameter name.

Contract testing: You can run a test suite that verifies the live server matches the spec. Catch regressions before they reach production.

Consistent error responses: Define your error schemas once; every endpoint uses them.

The Spec

OpenAPI 3.1 adopted JSON Schema fully, which means you can use $ref to share schemas across your spec and use the full JSON Schema vocabulary.

A minimal spec for a user management API:

# openapi.yaml
openapi: 3.1.0
info:
  title: User API
  version: 1.0.0

paths:
  /users:
    post:
      operationId: createUser
      summary: Create a new user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          $ref: '#/components/responses/ValidationError'
        '409':
          $ref: '#/components/responses/ConflictError'

  /users/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          $ref: '#/components/responses/NotFoundError'

components:
  schemas:
    CreateUserRequest:
      type: object
      required: [email, name]
      properties:
        email:
          type: string
          format: email
        name:
          type: string
          minLength: 1
          maxLength: 100

    User:
      type: object
      required: [id, email, name, createdAt]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        name:
          type: string
        createdAt:
          type: string
          format: date-time

  responses:
    ValidationError:
      description: Request validation failed
      content:
        application/json:
          schema:
            type: object
            required: [error, details]
            properties:
              error:
                type: string
              details:
                type: array
                items:
                  type: object
                  properties:
                    field:
                      type: string
                    message:
                      type: string

Write this spec before a line of server code exists. Review it with the team (or client). Once everyone agrees on the contract, generate.

Generating Server Types in TypeScript

openapi-typescript generates TypeScript types from a spec with zero runtime overhead:

npm install -D openapi-typescript
npx openapi-typescript openapi.yaml -o src/generated/api.d.ts

The output is a types-only file. You get the schema types and response types for every endpoint:

import type { components, paths } from './generated/api.d.ts';

type User = components['schemas']['User'];
type CreateUserRequest = components['schemas']['CreateUserRequest'];

// The path types tell you exactly what parameters and responses look like
type GetUserResponse = paths['/users/{id}']['get']['responses']['200']['content']['application/json'];

Run this generation step in CI. If the spec changes and the types change, TypeScript will tell you everywhere in the codebase that’s now broken.

Runtime Validation with express-openapi-validator

Types catch errors at compile time. Request validation at runtime catches what clients send that doesn’t match your spec.

npm install express-openapi-validator
import express from 'express';
import OpenApiValidator from 'express-openapi-validator';

const app = express();
app.use(express.json());

app.use(
  OpenApiValidator.middleware({
    apiSpec: './openapi.yaml',
    validateRequests: true,
    validateResponses: true,    // catches response bugs during development
  })
);

// Your handlers — request bodies are already validated before reaching here
app.post('/users', async (req, res) => {
  const body: CreateUserRequest = req.body;  // guaranteed to match the schema
  const user = await userService.create(body);
  res.status(201).json(user);
});

// Error handler for validation failures
app.use((err: any, req: any, res: any, next: any) => {
  if (err.status === 400) {
    res.status(400).json({
      error: 'Validation failed',
      details: err.errors,
    });
  } else {
    next(err);
  }
});

With validateResponses: true in development, you’ll get errors when a handler returns a response that doesn’t match the spec. Useful for catching bugs before they reach clients.

Generating Client SDKs

openapi-fetch (from the openapi-ts project) gives you a type-safe fetch client generated directly from your spec:

npm install openapi-fetch
import createClient from 'openapi-fetch';
import type { paths } from './generated/api.d.ts';

const client = createClient<paths>({ baseUrl: 'https://api.yourapp.com' });

// Fully typed — TypeScript knows the params, body, and response type
const { data, error } = await client.POST('/users', {
  body: {
    email: 'user@example.com',
    name: 'Test User',
  },
});

if (data) {
  console.log(data.id);  // string — TypeScript knows this
}

If the spec changes (say you add a required field to CreateUserRequest), the generated types update and every client call that’s missing the field becomes a TypeScript error. The contract is enforced across the codebase automatically.

TypeSpec: Generating OpenAPI From Code

For teams that find writing YAML by hand tedious, TypeSpec (from Microsoft) is the reverse approach: write the API description in a TypeScript-like DSL, generate the OpenAPI spec from it.

npm install -g @typespec/compiler
tsp init
// main.tsp
import "@typespec/http";

using TypeSpec.Http;

@service({ title: "User API" })
namespace UserAPI;

model User {
  id: string;
  email: string;
  name: string;
  createdAt: utcDateTime;
}

model CreateUserRequest {
  email: string;
  name: string;
}

@route("/users")
interface Users {
  @post
  create(@body body: CreateUserRequest): User | ValidationError;

  @get
  @route("{id}")
  get(@path id: string): User | NotFoundError;
}
tsp compile . --emit @typespec/openapi3

This generates openapi.yaml from the TypeSpec. TypeSpec handles versioning, pagination, and common patterns better than hand-written YAML for large APIs.

What This Changes in Practice

The discipline shift is real: you have to agree on the API contract before writing code. That’s a conversation that’s often deferred when teams code-first, which is part of why API design decisions get made late: in code review, or when a client tries to integrate and finds the shape unexpected.

OpenAPI-first makes that conversation happen at the right time. The spec is a document everyone can read, not just developers. Product managers and clients can review the API design before any code exists. Changes to the contract are explicit (they require spec changes) rather than accidental (someone refactors a handler and the response shape quietly changes).

For agencies shipping APIs that clients will integrate against, this matters a lot. A spec-first contract is also a scope boundary: deviations from the agreed spec are change requests, not bugs.

The toolchain (openapi-typescript for types, express-openapi-validator for runtime validation, openapi-fetch for clients) has zero lock-in. The OpenAPI spec is a standard. Switch frameworks, switch languages, switch tools: the spec stays.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored