Zod Validation Schema in TypeScript - Tighten Your API Boundary
A loose Zod schema lets bad request bodies through to your handler, where they crash on undefined properties two calls later. Tightening the schema at the boundary - with .strict(), .min(1), and safeParse - stops invalid data before it reaches your business logic.
The problem with a loose schema
If your Zod schema is missing constraints, requests like {} or
{"customer_id": "not-a-number"} pass .parse() and reach the handler.
The handler then explodes with Cannot read properties of undefined deep
inside pricing logic - far from where the bad data entered. The fix is to
make the schema describe exactly what is valid and reject everything else
at the API boundary.
A strict, production-ready Zod schema
Here is a complete example for a POST /api/orders endpoint in Fastify:
import { z } from "zod";
const ItemSchema = z
.object({
sku: z.string().min(1),
quantity: z.number().int().positive(),
})
.strict();
const OrderSchema = z
.object({
customer_id: z.number().int().positive(),
items: z.array(ItemSchema).min(1),
notes: z.string().max(200).optional(),
})
.strict();
Key choices:
- z.number().int().positive() - rejects floats, zero, and negatives
- z.array(ItemSchema).min(1) - rejects an empty items list
- z.string().max(200).optional() - allows the field to be absent, but
enforces a length cap when it is present
- .strict() on every object - rejects any key not declared in the schema,
so callers cannot sneak through undocumented fields
Validate with safeParse and return structured errors
Use safeParse instead of parse so validation errors do not throw:
fastify.post("/api/orders", async (request, reply) => {
const result = OrderSchema.safeParse(request.body);
if (!result.success) {
return reply.code(400).send({
error: "validation_failed",
issues: result.error.issues,
});
}
// result.data is fully typed - TypeScript knows every field is valid
const order = result.data;
const total = computeTotal(order.items);
return reply.code(201).send({
order_id: Math.floor(Math.random() * 100000),
customer_id: order.customer_id,
total,
});
});
result.error.issues is Zod's standard issue array - each entry names the
field path and the reason it failed, which gives callers an actionable
error message instead of a cryptic 500.
Why this matters
Once safeParse succeeds, TypeScript narrows result.data to the exact
inferred type of OrderSchema. You never need to cast or check for
undefined downstream - the type system and the runtime agree. That is the
key advantage of Zod over manual if checks: the validation and the types
stay in sync automatically.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Writing strict Zod schemas with required types, ranges, and .strict() to reject unknown keys
- Using safeParse to return structured 400 errors with Zod's issue list
- Validating nested arrays with per-item object schemas
FAQ
How do I reject unknown fields in a Zod schema?
Call .strict() on z.object({...}). By default Zod strips unknown keys silently; .strict() makes it return a validation error instead, so callers cannot pass undocumented fields.
What is the difference between Zod's parse and safeParse?
parse throws a ZodError on invalid input. safeParse returns {success: true, data} or {success: false, error} without throwing, so you can handle the error path cleanly and return a 400 instead of crashing the handler.
How do I make a field optional but still validate its value when present?
Chain .optional() after the other validators - for example z.string().max(200).optional(). Zod applies the max(200) check only when the field is present; if it is absent the field is simply undefined.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →