
TypeScript Types Vanish at Runtime
Your compile-time safety does nothing to a malformed request body. One schema per route closes the gap.
TypeScript types are erased at compile time. They describe what you believe about data, and they do nothing whatsoever to a request body that arrives shaped differently.
That gap — between what the compiler promised and what the network delivered — is where a surprising share of production bugs live.
The illusion
app.post('/api/bookings', (req, res) => {
const body = req.body as CreateBooking // a lie you told the compiler
createBooking(body) // and now believe
})
as asserts. It does not check. The request can contain anything, and now it flows through your application wearing a type it does not have — surfacing three layers down as an unhelpful error, or worse, as a record written with missing fields.
The fix is one line per route
const CreateBooking = z.object({
serviceId: z.string().uuid(),
date: z.coerce.date(),
guests: z.number().int().min(1).max(20),
notes: z.string().max(500).optional()
})
app.post('/api/bookings', (req, res) => {
const parsed = CreateBooking.safeParse(req.body)
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.flatten() })
}
createBooking(parsed.data) // typed, and actually verified
})
z.infer gives you the TypeScript type from the same schema, so there is one definition rather than a type and a validator that drift apart.
The constraints that matter most
max() on every string. Unbounded text fields are how a database column ends up holding a megabyte of junk that a template later renders.
int() where you mean integers. 1.5 guests should be rejected at the boundary, not discovered in a report.
coerce for dates and numbers arriving from forms and query strings as text — otherwise you write conversion code in every handler.
And be strict about unknown keys on anything sensitive, so a client cannot submit fields you did not intend to accept.
Where else it belongs
Third-party API responses. An external service changing its shape is not hypothetical. Validating at that boundary turns a confusing downstream crash into a clear error at the source.
Environment variables. Validate config at startup and fail immediately with a readable message, rather than discovering a missing variable when the first request arrives.
Shared between frontend and backend. One schema validating both the form and the endpoint means the two can never disagree about what a valid submission is — which is the main reason I put schemas in a shared package.
Resources
- Repo: colinhacks/zod
- Docs: zod.dev
- Video walkthroughs: YouTube: Zod validation tutorial
- Related: One monorepo per client
Need this built properly?
I build secure, fast, bilingual platforms for clients across Egypt, Saudi Arabia, the UAE and Kuwait.


