ehsan.blog
~/blog/api-client-part-12-runtime-validation-drift — zsh
cat api-client-part-12-runtime-validation-drift.md

When your types lie: runtime validation and drift detection

·5 min read

Codegen, from the last post, solves a build-time problem: types generated from a spec file. This post is about a runtime problem that codegen cannot solve on its own — what happens when the live backend quietly stops matching that spec.

The gap codegen can’t close

Problem: you generated types from openapi.yaml on Monday. On Wednesday, a backend engineer — on a different team, in a different repo, who’s never heard of your frontend’s codegen step — ships a change that renames a field, without touching the spec file. Your generated types still say the old field name. TypeScript still compiles fine, because TypeScript has no idea the backend changed; it only knows what the spec said three days ago. Your app runs, reads response.data.userName, gets undefined, and the bug surfaces as a blank name field in production, discovered by a support ticket instead of a build failure.

This is what I mean by drift — the generated types and the live backend have quietly diverged, and nothing in a purely build-time codegen pipeline catches it, because codegen only ever looks at the spec file, never at what the backend actually sends.

Runtime validation — checking real responses against the spec, live

Solution: enable schema validation, and every response gets checked against its expected shape as the response arrives, not just at generation time.

ts
createClient({
  openapi: {
    mode: 'runtime', // or 'codegen' — validation works with either
    validateResponses: true,
  },
})
plaintext
Response arrives


Does it match the schema for this endpoint?

   ┌──┴──┐
  yes    no
   │      │
   ▼      ▼
return   ValidationError (or warn, depending on config) — see below
  data

This is a distinct mechanism from the ValidationError in the error-handling post — that one was about your outgoing request failing 4xx validation on the server. This is the client validating an incoming response against what the spec said it should look like, catching the case where the backend itself has drifted from its own documented contract.

Strict vs warn — a real tradeoff, not a default to ignore

ts
openapi: {
  validateResponses: true,
  onValidationFailure: 'warn', // or 'throw'
}

throw — a schema mismatch becomes a hard error immediately. Good for staging and CI, where you genuinely want to know the moment a backend change breaks the contract, before it ships.

warn — logs the mismatch but still returns the (possibly malformed) data, letting your app keep running. This is what I actually run in production. Real talk: the first time I ran throw in production, one backend team’s minor, backward-compatible-in-practice spec drift turned into a hard outage for an endpoint that was functionally fine — the field that drifted wasn’t even one my UI used. warn gets you the same visibility without turning every spec disagreement into a user-facing failure.

Drift detection — comparing spec to reality on a schedule, not per-request

Problem: per-request validation catches drift reactively, one response at a time, only for endpoints your app actually calls. It won’t tell you that an endpoint nobody’s called in production yet has already drifted — you’d only find out the first time a user hits it.

Solution: a separate drift-detection process, run on a schedule (I run mine nightly in CI), that hits your live backend’s actual endpoints and compares real responses against the committed spec — proactively, across your whole API surface, not just the parts currently in traffic.

bash
npx api-client drift-check --spec ./openapi.yaml --baseURL https://api.example.com
plaintext
Checked 42 endpoints against live backend

⚠ GET /users/{id}
  Spec says `role: string`
  Backend actually returns `role: string | null`

⚠ POST /invoices
  Spec says response includes `taxAmount`
  Backend response is missing this field

✓ 40 other endpoints match

This becomes a Slack notification in CI on my projects, not a blocking gate — drift here means “go talk to the backend team about a spec update,” not “fail the build.” Treating it as a hard CI failure just means someone eventually adds an override to skip it under deadline pressure, and then you’ve lost the signal entirely.

How this fits with codegen from the last post

Together, these two posts cover the full lifecycle of “does my frontend’s understanding of the API match reality”:

plaintext
Spec file

   ├─▶ codegen (build time)  →  types.ts, matches spec AS OF generation

   └─▶ drift-check (scheduled) →  is spec still true, right now, against live backend?


                              runtime validation (per request)
                              →  did THIS specific response match?

Codegen answers “what did the spec say.” Drift-check answers “is the spec still accurate, across the whole API.” Runtime validation answers “did this one response, right now, actually match.” All three matter, and they answer genuinely different questions — I didn’t fully appreciate that until I’d set up all three and watched drift-check catch something runtime validation hadn’t, because nothing had hit that specific endpoint in production yet.

What sticks from this post

  • Codegen only knows what the spec said at generation time — it can’t catch a backend that’s drifted since, because it never looks at live responses.
  • Runtime validation checks each response against the schema as it arrives — warn in production to stay informed without breaking things, throw in staging/CI where you want to catch drift before it ships.
  • Scheduled drift-check compares your whole API surface against the live backend proactively, not just the endpoints currently receiving traffic.
  • These three mechanisms answer different questions and are worth running together, not as alternatives to each other.

Next: the TanStack Query integration — React, Vue, and Solid, and how the codegen descriptor map means you’re not hand-writing query keys.

  • developerehsan
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.