5 min read
Every UK backend team I have worked with has a swagger page that is wrong. GOV.UK Verify, a Monzo internal service I audited, an HMRC integration at a fintech I will not name: same story. The docs say one field is optional; the prod endpoint rejects the request when it is missing. The docs say amount is a number; the API happily accepts the string "12.34" and you find out three weeks later when the reconciliation job blows up. The root cause is not laziness. It is that documentation is a separate artefact that lives in a separate repo, edited by a different person, reviewed on a different cadence, and compiled by a tool that never sees your runtime. When you write TypeScript on the server, your Zod schema is the only thing that is actually exercised on every single request. That makes it more important than your API documentation. Treat it accordingly.
OpenAPI, AsyncAPI, Stoplight, Redoc, Postman collections: pick your poison, they all rot at the same rate. The rot starts the day after you merge. Someone tightens a regex in the handler, forgets to update the YAML, ships it. Three sprints later the docs and the code disagree and nobody can tell which one is right. This is not a tooling failure, it is a structural one. Documentation is prose. Prose has no compiler.
A Zod schema is not prose. It is executable, imported by the handler, and run on the edge of your system where the request meets your code. If the schema is wrong, the request fails immediately and the bug shows up in your CI the first time someone exercises that path. That is the feedback loop you want. A schema error is a type error in production. A documentation error is a misunderstanding in a Slack thread six months from now.
The classic mistake is to lean on TypeScript interfaces for your request and response shapes. They are erased at runtime. Once the JSON arrives from the wire, TypeScript has stepped out of the room and you are parsing by hand with as casts and crossed fingers. That is how the "12.34" string sneaks past your number interface and into your database. Interfaces are a lie at the boundary; Zod is the truth.
Here is the pattern I ship on every Node service, lifted from a Litestar-style FastAPI rebuild of a BBC internal admin tool. One schema, two uses: validation at the edge, type inference everywhere else.
import { z } from "zod";
const CreateTransferRequest = z.object({
sourceAccountId: z.string().uuid(),
destinationAccountId: z.string().uuid(),
amountMinor: z.number().int().positive().max(10_000_000),
currency: z.enum(["GBP", "EUR", "USD"]),
reference: z.string().min(1).max(140),
idempotencyKey: z.string().uuid(),
}).strict();
type CreateTransferRequest = z.infer<typeof CreateTransferRequest>;
export async function createTransfer(rawBody: unknown) {
const parsed = CreateTransferRequest.safeParse(rawBody);
if (!parsed.success) {
throw new BadRequestError(parsed.error.flatten());
}
// parsed.data is now narrowed and trusted.
return transferService.create(parsed.data);
}
The handler does not import a hand-written interface. It imports the schema. The schema validates, the schema infers the type, and there is exactly one source of truth. When you change amountMinor to amountMinor: z.bigint().positive() for a future pence precision push, TypeScript fails in the consuming code at build time. Your OpenAPI YAML, which you probably forgot to touch, does not.
If you still need an OpenAPI document for partners, generate it from Zod with @asteasolutions/zod-to-openapi or zod-openapi. The schema stays the source; the YAML becomes a build artefact in dist/openapi.yaml. I have shipped this on a GOV.UK-style public endpoint and the drift stopped the same week. Reviewers stopped approving hand-edited OpenAPI PRs because nobody had to write them.
Three recurring sins I see in UK codebases from Cardiff to Edinburgh:
z.infer and delete the interface.z.object({ ... }).passthrough() silently swallows unknown fields. Use .strict() by default. You want to know when a partner sends amountPounds instead of amountMinor, not a year from now./types. If your validation is in /types and your handlers are in /routes, nothing forces them to meet. Co-locate schemas with handlers, or better, put them in /schemas and require handlers to import from there with an ESLint rule.The third one is cultural, not technical. On a recent rebuild for a Leeds-based scaleup, we added a single ESLint rule banning RequestHandler signatures from using hand-written interfaces. Every handler now imports a schema. Bug count at the request boundary dropped to near zero in the first month.
.strict() and shared error envelopesTwo small schema habits compound faster than anything else. First, .strict() on every request schema. Second, a shared error envelope so every 4xx response looks the same shape. Zod makes both trivial.
const ErrorEnvelope = z.object({
error: z.object({
code: z.string(),
message: z.string(),
fieldErrors: z.record(z.array(z.string())).optional(),
requestId: z.string().uuid(),
}),
});
function toErrorResponse(zodError: z.ZodError, requestId: string) {
return ErrorEnvelope.parse({
error: {
code: "INVALID_REQUEST",
message: "Request failed schema validation",
fieldErrors: zodError.flatten().fieldErrors,
requestId,
},
});
}
Your mobile team, your public API consumers, and your internal admin UI all get the same error shape for free. No documentation needed. The schema is the documentation, with teeth.
Pick one endpoint. Find its handler. Find the OpenAPI block describing it. Replace the hand-written interface with a Zod schema, export its inferred type, and wire the handler through safeParse. Delete the OpenAPI block for that endpoint and regenerate it from the schema. Ship it. Watch your partner team stop filing tickets about "the docs say X but the server says Y." Repeat until there are no hand-written interfaces left at the request boundary.
Documentation is for humans reading out of band. Schemas are for the runtime, the compiler, and the next person who refactors the handler. When you run TypeScript on the server, bet on the thing the compiler can see. Bet on Zod.
On modern Node 20+ runtimes, Zod 3.23+ parses a typical 10-field request object in under 100 microseconds. For 99% of UK business endpoints, that is invisible. If you are at the edge of an exchange or a high-frequency path, use Valibot or a hand-rolled validator, but you almost certainly are not.
For the schema definitions, yes, and you should generate OpenAPI from Zod rather than the reverse. OpenAPI still has value for SDK generation, Postman collections, and partner portals, but treat it as a build artefact. Never let a human edit it directly.
Useful for the client side, harmful for the server side. Generated types give you compile-time confidence about a shape that nobody validates at runtime. On the server, you receive unknown from the wire and you need a runtime guard. That is Zod's job. Generated types and Zod complement each other; do not let them compete.