Skip to content
intermediate Phase 10 · TypeScript Schema Validation

API Request Validation

Validate API requests and responses with typed schemas.

1h
0 problems
Topic Progress 0%

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 CreateOrder compiles anywhere — types do not exist at runtime; the as hides 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 a partial() 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?

Question 1 options

2. Why validate responses as well as requests?

Question 2 options

3. Query parameters arrive from a URL as…

Question 3 options

Flashcards

Question

Trust boundary

Answer

The edge where untrusted bytes become typed data — parse there and nowhere else.

Question

Response schema

Answer

Zod schema for outgoing JSON; parse in tests/CI to detect contract drift early.

Question

PATCH validation

Answer

partial() fields + refine(at least one key) — all optional but never empty.

Question

Validation error envelope

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.

Continue Learning