Skip to main content
node

OpenAPI Is Also a Design Artifact

OpenAPI specs are not just documentation to generate after the fact. They are the best tool for designing an API before writing a line of implementation.


5 min read

An OpenAPI spec is a contract. It says what status codes the client can expect, what error shape errors take, how pagination works, and what fields are required. When you write the spec after the implementation, it is documentation — sometimes accurate, often not. When you write the spec before the implementation, it is a design tool that lets you catch mistakes before they exist in production and generate types before writing a single handler.


The Spec as Prototype

The fastest way to prototype an API without spinning up a server is to write the OpenAPI spec and use it as a mock. Tools like Prism from Stoplight can serve a mock server from any OpenAPI file:

npx @stoplight/prism-cli mock ./openapi.yaml

The frontend team can build against a real HTTP interface with real status codes and example payloads while the backend team implements the actual handlers. The spec is the handshake. Both sides build toward it simultaneously.

This catches design problems before they cost anything. If the frontend team notices the error shape is inconsistent halfway through mocking, fixing the YAML takes minutes. Fixing a deployed API with live clients takes a deprecation cycle.

What a Well-Designed Spec Looks Like

A single endpoint should communicate: success shape, every error condition with its own schema, and what authentication it requires. The error schemas matter most — they are what clients actually need to handle.

# openapi.yaml (partial)
paths:
  /projects/{id}:
    get:
      operationId: getProject
      summary: Get a single project by ID
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Project found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Project"
        "401":
          description: Not authenticated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UnauthorizedError"
        "403":
          description: Authenticated but not permitted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForbiddenError"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotFoundError"

components:
  schemas:
    Project:
      type: object
      required: [id, title, status, createdAt]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        status:
          type: string
          enum: [draft, active, archived]
        createdAt:
          type: string
          format: date-time

    NotFoundError:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
          example: "Project not found"
        code:
          type: string
          enum: [NOT_FOUND]

    UnauthorizedError:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
        code:
          type: string
          enum: [UNAUTHORIZED]

    ForbiddenError:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
        code:
          type: string
          enum: [FORBIDDEN]

The error schemas use a machine-readable code field alongside the human-readable error string. This lets frontend code switch on body.code without string-matching error messages — the messages can change for copy reasons without breaking client logic.

Type Generation from the Spec

openapi-typescript generates TypeScript types directly from the spec. The generated types become the single source of truth for both the frontend HTTP client and the backend response validators.

npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts

The generated file contains types for every request, response, and schema in the spec. Use openapi-fetch to wire them to a typed fetch client:

import createClient from "openapi-fetch";
import type { paths } from "./types/api";

const client = createClient<paths>({ baseUrl: "/api" });

// TypeScript knows the return type, the path params, and the error shapes
const { data, error } = await client.GET("/projects/{id}", {
  params: { path: { id: projectId } },
});

if (error) {
  // error is typed — code is "NOT_FOUND" | "UNAUTHORIZED" | "FORBIDDEN"
  console.error(error.code);
}

The type paths encodes the entire API surface: every path, method, parameter, and response. If the spec changes, regenerate, and TypeScript points to every broken usage.

The Spec as Source of Truth

When the spec is authoritative, the implementation serves the spec — not the other way around. Validation libraries like @readme/openapi-validator or Zod schemas generated from the spec can verify at runtime that responses match what was promised.

This matters most at the boundary between teams. When the frontend knows it can trust the spec, it can write types and client code before the backend is deployed. When the backend knows the spec is the test, it implements against a clear target rather than guessing what the frontend needs.

Writing the spec first is not a ceremony. It is the API design session.