What OpenAPI-first means and when it beats code-first

In code-first, decorators or route definitions are the source of truth, and the OpenAPI document is generated from them. In contract-first, the spec is written and reviewed before any handler exists. Types, validators, and client SDKs are all derived from that spec, and nothing derived from it is edited by hand.

Code-first breaks down when several teams or consumers share one API. The spec becomes a by-product of whoever merged last, so breaking changes appear in a generated diff that nobody reviewed. Contract-first moves that review to the start, before frontend, mobile, and partner teams build against the change.

Contract-first has its own failure modes. YAML is slower to write than a route definition at first. Without spec review discipline, the spec becomes a bottleneck or quietly drifts from the code.

Contract-first Code-first
Source of truth Reviewed spec Server code
Good fit Platform teams, shared APIs, multiple client SDKs Small single-consumer services, prototypes
Main risk Spec review becomes a bottleneck Unreviewed breaking changes

There are two other options. In schema-library-first approaches, a runtime schema produces both the types and the spec. For an internal service with one consumer, hand-written types may be enough.

The pipeline this tutorial builds:

openapi.yaml (reviewed) → openapi-typescript → compile-time types → handlers type-check against them → Ajv → runtime validators → requests are checked at the boundary → CI regenerates both and fails if either differs from what is committed

                        +----------------------------+
                        | openapi.yaml (reviewed)    |
                        | single source of truth     |
                        +-------------+--------------+
                                      |
          +---------------------------+---------------------------+
          |                           |                           |
          v                           v                           v
+--------------------+     +----------------------+     +--------------------+
| openapi-typescript |     | CI                   |     | Ajv                |
+---------+----------+     | regenerate types and |     +---------+----------+
          |                | validators from the  |               |
          v                | spec, then diffs the |               v
+--------------------+     | output against the   |     +--------------------+
| compile-time types |<----| committed files      |---->| runtime validators |
| (never hand-edited)|     | fails if either one  |     | (never hand-edited)|
+---------+----------+     | differs              |     +---------+----------+
          |                +----------------------+               |
          v                                                       v
+--------------------+                                  +--------------------+
| handlers type-check|                                  | requests checked   |
| against the types  |                                  | at the boundary    |
+--------------------+                                  +--------------------+

The reviewed openapi.yaml is the only source of truth: openapi-typescript generates compile-time types for handlers, Ajv generates runtime validators for incoming requests, and CI regenerates both from the spec and fails if either differs from the committed output.

How the spec-to-code pipeline works

The pipeline has three stages, and each one reads the same file.

  1. Authoring and review. The API change is written in openapi.yaml and reviewed like any other code. This is the only place where the request and response shapes are defined.
  2. Compile-time generation. openapi-typescript reads the spec and writes a .d.ts file with no runtime code. It turns each schema into a TypeScript type and exposes them through the paths, components and operations interfaces. Handlers import these types, so the TypeScript compiler rejects any handler that reads a field the spec does not define or returns a body of the wrong shape.
  3. Runtime validation. At process startup the server loads the spec, gives its JSON Schema fragments to Ajv, and compiles them into validator functions. A middleware runs the matching validator against each incoming request before the handler sees it. Requests that fail get a 400 response. This covers the gap that types leave, because types are erased when the code is compiled and do nothing against a malformed payload at runtime.

CI ties the stages together. It regenerates the types from the spec, compares them with what is committed, and runs contract tests that check real responses against the spec's response schemas. If the spec changes and the code does not, either the type check or the diff fails.

The derived artifacts never feed back into the spec. Changing a type or a validator means changing the spec and regenerating.

When to use OpenAPI-first

Contract-first earns its overhead under these conditions:

  • Several consumers share the API. Frontend, mobile, partner integrations or other backend services can each build against the reviewed spec before the server work is finished. They can also generate their own clients from it.
  • Breaking changes are expensive. Public or partner APIs, and APIs whose clients you cannot redeploy on your own schedule (such as installed mobile apps), benefit from reviewing the contract before code exists.
  • You need types and runtime validation to agree. Deriving both from one file removes a whole class of bugs in which the TypeScript type accepts something the validator rejects, or the reverse.
  • API design is reviewed by people who do not read the server code. A spec diff is readable by API reviewers, technical writers and consumer teams in a way a handler diff is not.
  • The API spans languages. If some consumers or services are not written in TypeScript, a language-neutral spec is a better source of truth than TypeScript code.

When to avoid it

Contract-first is the wrong choice, or not worth it yet, in these situations:

  • Prototypes and APIs whose shape changes daily. Each iteration costs a spec edit, a regeneration and a review. Code-first, or no formal spec at all, is faster until the design settles.
  • A single consumer in the same repository. If one frontend and one backend share a codebase and ship together, sharing TypeScript types directly gives most of the safety with less machinery.
  • Non-HTTP or poorly described interfaces. Streaming protocols, WebSocket message flows and RPC-style APIs that OpenAPI describes badly will fight the tooling.
  • No one will own the spec. If the team will not review spec changes or keep the CI gates green, the spec drifts and becomes misleading documentation. That is worse than having none.
  • Schemas that rely on heavy composition. If the domain needs deep oneOf/allOf structures that the generators render poorly or that openapi-typescript and Ajv interpret differently, the generated output may cost more to work around than it saves.

Alternatives to OpenAPI-first

  • Code-first spec generation. Route definitions or decorators in the server framework produce the OpenAPI document. This suits teams that want a published spec without maintaining YAML. The cost is that the spec reflects whatever was merged rather than what was reviewed.
  • Schema-library-first. A runtime validation library defines each schema in TypeScript. Static types are inferred from those schemas, and an OpenAPI document can be generated from them as well. Types and validation stay in sync without a separate generation step for types, but the source of truth lives in TypeScript code, which non-TypeScript consumers cannot read directly.
  • JSON Schema with framework-native validation. Some Node.js frameworks accept JSON Schema per route and validate requests with it. You can derive types from those schemas. This skips OpenAPI entirely when no published spec is needed.
  • Hand-written types plus manual validation. For a small internal service with one consumer, an interface file and a few explicit checks may be enough. Nothing keeps them in sync automatically, so this only works while the surface stays small.
  • A different contract format. If you control both ends and do not need plain JSON over HTTP, a schema-based RPC format with its own code generators avoids OpenAPI's JSON Schema mismatches. It gives up the broad HTTP tooling ecosystem in exchange.

Structuring the spec so generation produces usable output

Generators can only name what the spec names. Three structural habits decide whether the generated output is usable or just technically correct.

Put reusable schemas under components/schemas

A schema defined inline in a request body or response has no name. openapi-typescript renders it as an anonymous object type nested deep inside paths. To use it in a handler you either retype it or write a long indexed-access chain through the path, method, response code and content type. The same schema moved to components/schemas/User becomes components["schemas"]["User"], which you can alias once and import everywhere. Ajv benefits too: a named schema can be registered once and referenced, rather than compiled again for every operation that embeds a copy.

Give every operation a stable operationId

Without an operationId, the only way to reach an operation's types is through its path string, such as paths["/users/{id}"]["get"]. If the URL is renamed, every handler that referenced that string breaks, even though the contract did not change. With an operationId, the generated operations["getUser"] entry stays stable across URL changes. It also gives you a natural key for mapping handlers and validators.

Use $ref, not duplication

Two inline copies of the same shape produce two unrelated types. They drift as soon as someone edits one copy.

Restraint matters too. Heavy allOf/oneOf compositions become nested intersections and unions that are hard to read in editor tooltips and error messages. Long $ref chains also make the TypeScript checker do more work. Keep compositions shallow, and use a discriminator with oneOf where possible.

// Generated with: npx openapi-typescript openapi.yaml -o src/generated/api.d.ts
import type { components, operations, paths } from "./generated/api";

// Inline schema in the spec: no name, so you must dig through path, method,
// status code and media type. This breaks if "/users/{id}" is renamed.
type UserFromPath =
  paths["/users/{id}"]["get"]["responses"][200]["content"]["application/json"];

// Schema under components/schemas/User: one short, stable name to alias and reuse.
export type User = components["schemas"]["User"];

// operationId "getUser": keyed by the contract, not the URL string.
type GetUser = operations["getUser"];
export type GetUserParams = GetUser["parameters"]["path"];
export type GetUserOk =
  GetUser["responses"][200]["content"]["application/json"];

// Handlers keyed by operationId. Renaming the path in the spec leaves this map intact.
type Handlers = {
  getUser: (params: GetUserParams) => Promise<GetUserOk>;
};

export const handlers: Handlers = {
  async getUser({ id }) {
    // GetUserOk is a $ref to User, so it is the same type, not a drifting copy.
    const user: User = { id, email: "ada@example.com", name: "Ada" };
    return user;
  },
};

Types generated by openapi-typescript, showing how a named components/schemas entry and an operationId give short, stable type keys compared with indexed access through a path string.

Generating TypeScript types with openapi-typescript and binding handlers to them

Add a package script such as "gen:types": "openapi-typescript openapi.yaml -o src/generated/api.d.ts" so every developer and CI job runs generation the same way. Then decide where the output lives. If you commit it, the diff shows up in code review and reviewers can see exactly how a spec change alters the types. If you regenerate it at build time, the file can never drift from the spec, but every fresh checkout needs a generation step before the type checker can run. Teams that review API changes closely tend to commit the file. Either way, CI has to enforce the choice.

The generated file exports three main interfaces:

  • paths is keyed by URL template and then by HTTP method.
  • components holds your named schemas under components["schemas"].
  • operations is keyed by operationId, which is the most stable way to reference an operation because it survives path renames.

For an operation like createOrder, the useful types sit at fixed locations:

  • Request body: operations["createOrder"]["requestBody"]["content"]["application/json"]
  • Path params: operations["createOrder"]["parameters"]["path"]
  • Query params: operations["createOrder"]["parameters"]["query"]
  • Response: operations["createOrder"]["responses"][201]["content"]["application/json"]

You can wrap these lookups in a generic helper that takes a path and a method, derives the four types, and passes them to Express's RequestHandler<Params, ResBody, ReqBody, Query> or to a Fastify route generic. Once handlers are bound this way, renaming a field in the spec breaks compilation in every handler that reads or returns it. The mismatch is caught by the compiler instead of by a client in production.

These types describe data shape only. Business rules, cross-field constraints and authorization checks still belong in application code.

import express, { type RequestHandler } from "express";
// Generated by `npm run gen:types`; never edited by hand.
import type { operations } from "./generated/api";

type Op = keyof operations;
type Json<T> = T extends { content: { "application/json": infer B } } ? B : undefined;
type Clean<T> = [NonNullable<T>] extends [never] ? {} : NonNullable<T>;

// Derive each type from the operation, keyed by operationId so path renames don't matter.
type Params<K extends Op> = Clean<operations[K]["parameters"]["path"]>;
type Query<K extends Op> = Clean<operations[K]["parameters"]["query"]>;
type Body<K extends Op> = operations[K] extends { requestBody?: infer R } ? Json<NonNullable<R>> : undefined;
type Res<K extends Op, S extends keyof operations[K]["responses"]> = Json<operations[K]["responses"][S]>;

// Ties a handler to one operation and one success status. Zero runtime cost: it returns fn unchanged.
export function bind<K extends Op, S extends keyof operations[K]["responses"]>(
  _operationId: K,
  _status: S,
  fn: RequestHandler<Params<K>, Res<K, S>, Body<K>, Query<K>>,
): RequestHandler<Params<K>, Res<K, S>, Body<K>, Query<K>> {
  return fn;
}

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

// Assumes the spec defines createOrder with a JSON body { items } and a 201 response { id, status, items }.
app.post(
  "/orders",
  bind("createOrder", 201, (req, res) => {
    const { items } = req.body; // typed from the spec; renaming `items` there breaks this line
    // Shape is guaranteed by types only at compile time. Runtime validation, stock checks,
    // pricing rules and authorization still have to be written here.
    res.status(201).json({ id: "ord_123", status: "pending", items });
  }),
);

app.listen(3000);

This example derives request body, path, query and response types for the createOrder operation from the openapi-typescript operations interface, then binds an Express handler to them, so any change to the shape in the spec causes a compile error in the handler.

Runtime request validation with Ajv compiled from the same spec

TypeScript types are erased at compile time, so nothing stops a client from sending "limit": "ten" or omitting a required field. The running process needs its own check. Load the spec once at startup, register every schema under components/schemas with Ajv so $refs resolve, and compile one validator per operation for path parameters, query parameters and the request body. Compiling at boot means a broken schema fails the deploy instead of the first request.

Query and path values always arrive as strings. Ajv's coerceTypes option converts them to the declared number or boolean before validation. Keep it off for JSON bodies, where a string in a numeric field is a genuine client error. Build a small object schema for each operation's parameters and list required ones in required, because a parameter's required: true is not a JSON Schema keyword.

The spec dialect matters. OpenAPI 3.0 schemas use keywords like nullable and discriminator that are not standard JSON Schema, while 3.1 aligns with JSON Schema. Check how your Ajv setup handles the version you use.

Construct What to check in Ajv
nullable (3.0) Supported natively, or rewrite to a type array
discriminator Requires the discriminator option and has constraints on schema shape
readOnly / writeOnly Treated as annotations; strip readOnly fields from request schemas yourself
format Needs a formats plugin, otherwise it is unknown or ignored

openapi-typescript and Ajv parse the spec independently. They can disagree on oneOf versus anyOf, discriminators and additionalProperties. Test the edge cases against both.

Finally, coerceTypes, useDefaults and removeAdditional modify the request object in place. A client that sends misspelled fields or relies on defaults it never sets will appear to work. Log these changes, or reject the request in non-production environments.

import Ajv from "ajv";
import addFormats from "ajv-formats";
import { readFileSync } from "node:fs";

const spec = JSON.parse(readFileSync("./openapi.json", "utf8"));

// Two instances: query/path values arrive as strings and need coercion;
// JSON bodies must not be coerced, so "ten" in a number field stays an error.
function makeAjv(coerceTypes: boolean) {
  const ajv = new Ajv({ allErrors: true, coerceTypes, discriminator: true });
  addFormats(ajv); // without this, "format" is unknown or ignored
  ajv.addVocabulary(["components"]); // let strict mode accept the wrapper key
  // Register the components once so "#/components/schemas/..." refs resolve.
  ajv.addSchema({ $id: "openapi.json", components: spec.components });
  return ajv;
}
const paramAjv = makeAjv(true);
const bodyAjv = makeAjv(false);

// Parameter required: true is not JSON Schema, so build an object schema
// per operation and move required names into "required".
const listPetsQuery = paramAjv.compile({
  type: "object",
  properties: {
    limit: { type: "integer", minimum: 1, maximum: 100 },
    status: { type: "string", enum: ["available", "sold"] },
  },
  required: ["limit"],
});

// Body schema referenced straight from the spec; compiled at boot,
// so a broken schema fails the deploy rather than the first request.
const createPetBody = bodyAjv.compile({ $ref: "openapi.json#/components/schemas/NewPet" });

export function checkCreatePet(query: unknown, body: unknown) {
  // Note: coercion rewrites query values in place ("20" becomes 20).
  if (!listPetsQuery(query)) return { status: 400, errors: listPetsQuery.errors };
  if (!createPetBody(body)) return { status: 400, errors: createPetBody.errors };
  return null; // valid
}

Compiling validators at startup from the spec's component schemas, with coercion enabled only for string-typed query and path parameters and kept off for JSON bodies.

Where to validate and how to report errors to clients

Run validation once, at the edge of the routing layer: a single middleware that looks up the compiled validator for the matched operation, or the framework's own schema hook if it has one. If validation lives inside each handler, it will eventually be skipped in one of them, and handlers end up mixing transport concerns with business logic.

Every failure should produce the same 400 shape. Map each Ajv error's instancePath (for example /items/2/quantity) to a field path the client can act on, and pair it with a short human-readable reason. Omit schemaPath, internal $ref names, and raw keyword parameters. They describe your schema files rather than the client's mistake, and they leak structure you may want to change later.

oneOf failures cause the most confusing output. When no branch matches, Ajv reports errors from every branch, so a client sending a malformed card payment also sees complaints about bank-transfer fields. There are two fixes. Declare a discriminator on the property that selects the variant and enable Ajv's discriminator support, so only the selected branch is checked. Alternatively, collapse a failed oneOf into a single error at its parent path, such as "does not match any allowed payment type."

Response validation catches handlers that drift from the spec. It is cheap insurance in tests and staging. In production it costs CPU on every response, and it turns a harmless extra field into a 500 for a user. Limiting it to tests and staging leaves untested paths unchecked. One compromise is to validate a sample of production responses and log mismatches without blocking the response.

Keeping spec, types and validators in sync in CI

A sampled check catches drift in production, but it fires only after the drift has shipped. CI should catch it earlier with three gates, in this order.

Lint, regenerate, diff

First, lint the spec. Broken $refs, missing operationIds and inline schemas fail cheaply here, before they show up as unusable generated names. Next, run openapi-typescript and any Ajv build step exactly as a developer would locally, using the same script from package.json. Then run git diff --exit-code on the generated paths. A non-empty diff means someone edited the spec without regenerating, or edited generated output by hand. Either way the committed types no longer describe the spec, so the build should fail.

Contract tests

Matching types prove only that the code compiles against the spec. They do not prove that routes behave as the spec says. Contract tests start the real app, send requests built from the spec's examples, and validate each response against the operation's response schema with the same compiled Ajv validators. These tests catch handlers that return a field typed as optional but documented as required, or a status code the spec never declares.

Commit generated code or generate at build time?

Commit generated files Generate at build time
Review visibility Type changes show in the PR Changes are invisible unless reviewers regenerate
Diff noise Large diffs on spec edits Clean history
Build dependency None at runtime Generator must run in every build and IDE setup
CI check needed Diff against fresh output Generation must succeed and typecheck

Neither option is self-correcting. Committed files go stale without the diff gate. Build-time generation can still let a spec change break consumers unnoticed unless CI typechecks and runs the contract tests on every change.

Evolving the spec and the impact on cost and engineering time

Classify every spec change before merging it. The spec lives in the same repository as the handlers and changes in the same pull request, so a single commit history shows what the contract was at any release and why it changed.

Change Classification Why
New optional request field Additive Old clients never send it
New required request field Breaking Old requests now fail validation
New response field Usually additive Breaking for clients that reject unknown properties
Changed default Behavioural break Ajv's useDefaults fills it in silently, so handlers see different input
Narrowed enum or type Breaking Previously valid payloads are rejected

Defaults need extra care because they are easy to miss. The compiled types say the field is present, the validator supplies it, and a reviewer reading only the handler diff never sees the value change.

The approach has real costs:

  • Engineers write the spec before the code.
  • Someone on the team has to understand JSON Schema composition.
  • Every API change gets an extra review.
  • When discipline slips, people cast around generated types or edit the spec afterwards to match code that already shipped. The spec still exists but no longer drives anything.

Weigh those costs against what you get back:

  • Drift bugs become compile errors.
  • API changes have one review point.
  • Consumers import the same types the server uses.

Illustrative estimate (assumed numbers, not a benchmark). Use this formula:

Net hours = (drift bugs avoided × hours per bug) − (API changes × extra hours per change)

Assume 10 drift bugs avoided per year at 6 hours each, and 40 API changes per year at 1 extra hour each:

(10 × 6) − (40 × 1) = 20 hours saved per year

Replace these inputs with figures from your own incident and pull request history.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to openapi first node.js, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.