Home / Articles / Catching Silent API Contract Drift with Sample-Inferred Types and Zod

This article is published in English.

Catching Silent API Contract Drift with Sample-Inferred Types and Zod

Why hand-written TypeScript types for third-party APIs go stale, how inferring types and Zod schemas from real responses helps, and how snapshot diffs expose drift.

1565 words

Third-party APIs change shape without warning, and TypeScript will not notice, because your types describe what the response looked like when someone wrote them, not what it looks like today. This article walks through how that failure happens, why generating types from several real responses beats typing them by hand, and why the piece that actually protects you is comparing new responses against a saved snapshot. You will also see where this approach fits next to OpenAPI and contract testing, and where it does not.

How a renamed field slips through

Consider a frontend that integrates with a payment provider. One day, a field in one of the provider's responses changes from user_id to userId. There is no changelog entry, no announcement and no version bump. Most likely an engineer on the provider's side tidied up an inconsistent name, their test suite passed, and the change went out.

Nothing crashes on the consuming side, which is exactly the problem. The code still reads response.user_id, and TypeScript accepts it, because the interface was typed by hand months earlier from a Postman example that no longer reflects reality. That interface still promises a user_id field. At runtime the value is simply undefined. For two weeks, three code paths quietly write undefined into an amount field, until a support ticket finally exposes the bug. No alert fires, no build turns red, and the application keeps doing the wrong thing without complaint.

Teams that work with external APIs for long enough almost always meet some version of this incident.

The weak point is where the types come from

TypeScript is not at fault here. The problem is the origin of the types. Interfaces are usually treated as if they were derived from something authoritative, such as a schema, a contract or a single source of truth. In practice, many of them come from one example response that somebody transcribed by hand. That interface is then copied into several other files and treated as fact, and nobody looks at it again until something breaks.

The real contract is whatever the API returns in production right now. It lives on a server you do not control and can change without your consent. Hand-written types are a snapshot of a moment that has already passed, and the compiler has no way to know that.

There is also a deeper gap. TypeScript types disappear at compile time, so they never check anything at runtime. If the payload shape changes, only runtime validation at the boundary, for example parsing the response with a Zod schema, turns a silent undefined into an immediate, visible error.

Infer types from several real responses

What helps most here is not more advanced TypeScript or cleverer generics, but a mechanical process: take the responses the API actually returned, generate types from them, and get alerted as soon as reality stops matching.

The input should be real JSON responses, not documentation and not a schema. From them, a tool can infer both a TypeScript type and an equivalent Zod schema. Using several samples matters more than it might seem. One response shows what a payload can look like. Three or four responses reveal which fields are genuinely optional, which ones are sometimes null, and which array elements have inconsistent shapes. A single sample misleads by omission all the time.

The example below feeds in two responses for the same resource, one with user_id and one with userId, and shows the TypeScript type and Zod schema inferred from both:

// paste these two responses in...
[
  {
    "user_id": "pot_00009exampleP0tOxWb",
    "name": "Wedding Fund",
    "balance": 550100,
    "currency": "GBP",
    "created": "2025-11-09T12:30:53.695Z",
    "updated": "2025-02-26T07:12:04.925Z"
  },
  {
    "userId": "pot_00009exampleP0tOxWb",
    "name": "Wedding Fund",
    "balance": 550,
    "currency": "EUR",
    "created": "2025-11-09T12:30:53.695Z",
    "updated": "2025-03-26T07:12:04.925Z"
  }
]

// ...get this out typescript
type Root = {
  user_id?: string
  name: string
  balance: number
  currency: string
  created: string
  updated: string
  userId?: string
}[]

// or ... get this out zod
import { z } from 'zod'

const Root = z.array(z.object({
  user_id: z.string().optional(),
  name: z.string(),
  balance: z.number(),
  currency: z.string(),
  created: z.string(),
  updated: z.string(),
  userId: z.string().optional(),
}))

Look closely at what the merged result says. Because each name appears in only one sample, both user_id and userId become optional. That is technically accurate, but it also hides the rename: code that reads either field still type-checks, and a response containing neither would pass the Zod schema too. The samples also hint at a problem type inference can never catch: balance drops from 550100 to 550 while currency changes, which could mean a switch between minor and major currency units. The inferred type is number in both cases. Inference tells you the shape; it cannot tell you the meaning.

Many code generators stop at this point. Getting from untyped to typed is useful, but it does not solve the drift problem.

Snapshots and diffs catch the change

The more valuable step comes after generation. Once types are derived from a real response, that response can be stored as a snapshot. Every time you capture a fresh sample from the same endpoint, you compare it with the snapshot and get a precise report of what changed, whether that is a field under a new name, a value that is now nullable where it used to be a plain string, or an extra key appearing inside a nested object. Instead of a vague "something failed somewhere", you see the exact shape difference.

That comparison is what separates a type generator from a drift detector. Code generation gets you from nothing to typed code. Drift detection is what keeps a user_id to userId rename from sitting unnoticed in production for weeks. In the example above, a snapshot diff would report "user_id removed, userId added" rather than quietly widening both fields to optional.

Keep production payloads on your machine

Detecting real drift requires real data; synthetic payloads will not reveal the changes you care about. That makes privacy a design requirement. Production responses may contain customer information, so pasting them into a web form that uploads them to a third-party server creates a new data-handling risk. Tools for this job should run locally, for instance entirely in the browser tab or as a script in your own repository, so payloads never leave your environment.

Where this approach fits and where it does not

Sample-based inference with drift detection does not replace OpenAPI or a contract-testing setup such as Pact. If you own both the provider and the consumer and can enforce a schema at the source, do that; it is the better long-term answer.

This technique targets the more common and less glamorous situation: you consume an API you do not control, the documentation is outdated or missing, and generating a client from an OpenAPI spec is not an option because no spec exists or nobody trusts it. That describes most integrations with payment processors, internal services owned by other teams, and third-party vendor APIs. In that world, the actual response is the only ground truth available, so that is what your types should be derived from.

Keep the scope narrow. JSON in, TypeScript and Zod out, plus drift detection, covers the need. Trying to handle XML, protobuf and every schema edge case turns a sharp tool into a vague one. For a broader look at the contract decisions that tend to hurt frontends, see common API contract mistakes that break frontend reliability.

A practical first test

The best place to try this is an integration that has already been bitten by a silent shape change. Take an old response and a recent one from the same endpoint, run them through inference and a snapshot comparison, and review what gets flagged. Seeing a real historical change surface in the diff is more convincing than any argument for the approach.

Key takeaways

  • Hand-written interfaces for third-party APIs are snapshots of the past, and TypeScript cannot tell when they go stale.
  • Infer types and Zod schemas from several real responses, since multiple samples reveal optional, nullable and inconsistent fields that one example hides.
  • Validate responses at runtime at the API boundary so shape changes fail loudly instead of producing undefined.
  • Store responses as snapshots and diff new samples against them; merged inference alone can mask a rename as two optional fields.
  • Keep production payloads local, and prefer OpenAPI or contract tests whenever you control both sides of the API.