Validate at the Trust Boundary
Validate at the Trust Boundary
The network is hostile; parse before any handler logic:
import { z } from "zod";
const CreateOrder = z.object({
sku: z.string().min(1),
qty: z.number().int().positive(),
note: z.string().max(500).optional(),
});
type CreateOrder = z.infer<typeof CreateOrder>;
function validateBody<S extends z.ZodTypeAny>(schema: S, body: unknown):
| { ok: true; data: z.infer<S> }
| { ok: false; error: z.ZodError } {
const r = schema.safeParse(body);
return r.success ? { ok: true, data: r.data } : { ok: false, error: r.error };
}
Middleware (Express/Fastify) parses once, stores req.body as CreateOrder after success, and rejects with 400 + issues mapped to field paths. Path and query values are always strings — coerce (z.coerce.number()) or transform explicitly rather than trusting as unknown as casts deep inside the route.
Typed Handlers and Response Contracts
Typed Handlers and Response Contracts
Carry the validated type through the handler signature:
type Handler<B, P> = (req: { body: B; params: P }, res: Res) => Promise<void>;
const createOrder: Handler<CreateOrder, { tableId: string }> =
async (req, res) => {
const order = await service.place(req.body); // body: CreateOrder
res.status(201).json(OrderRes.parse(order)); // response validated too
};
Validating responses is the step teams skip: the schema becomes the contract test. A test fetches the endpoint, OrderRes.parse(await res.json()), and fails the moment an field is renamed — drift is caught in CI instead of by a consumer in production. For frameworks with JSON-schema plumbing (Fastify type providers), generate or bridge the Zod schema so route types and docs stay synchronized with one definition.
Pitfalls and Interview Angles
Pitfalls and Interview Angles
- Trusting erased types:
req.body as CreateOrdercompiles anywhere — types do not exist at runtime; theashides unchecked input. - Partial updates: PATCH bodies are all-optional but must contain at least one field —
schema.refine((v) => Object.keys(v).length > 0)on apartial()object. - Error shape discipline: return
{ error: { code: "VALIDATION", issues: [{path, message}] }} — never the raw stack or internal exception text; log details server-side with a request id. - Body size limits:
express.json({ limit: "64kb" })before parsing — validating a 50 MB body wastes CPU. - Content type: reject non-JSON uploads with 415 before schema parsing.
Interview angle: walk the pipeline — size limit → content type → safeParse → typed handler → response check → error envelope — and say where coercion happens.
Quiz
1. Why does "req.body as CreateOrder" not protect the handler?
2. Why validate responses as well as requests?
3. Query parameters arrive from a URL as…
Flashcards
Question
Trust boundary
Click to reveal answer
Answer
The edge where untrusted bytes become typed data — parse there and nowhere else.
Question
Response schema
Click to reveal answer
Answer
Zod schema for outgoing JSON; parse in tests/CI to detect contract drift early.
Question
PATCH validation
Click to reveal answer
Answer
partial() fields + refine(at least one key) — all optional but never empty.
Question
Validation error envelope
Click to reveal answer
Answer
{error:{code, issues:[{path, message}]}} — structured, no internal leakage.
Revision Notes
Key Takeaways
- 1. Parse once at the edge; handlers receive fully narrowed typed data
- 2. Response schemas double as contract tests against regressions
- 3. Keep error responses structured and bounded; limit body size before parsing
Interview Tips
- • Lay out the full middleware pipeline in order, naming where each check happens
- • Explain coercion at the edge versus casts deep in business logic and why only one is honest
Cheat Sheet
size limit → content-type → safeParse → typed handler → response parse in tests; z.coerce for query/env; PATCH = partial + refine non-empty; 400 envelope with issue paths.