Schemas as the Single Source of Truth
Schemas as the Single Source of Truth
TypeScript types vanish at runtime; a Zod schema validates and derives the type:
import { z } from "zod";
const User = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0).optional(),
role: z.enum(["admin", "editor", "viewer"]),
});
type User = z.infer<typeof User>; // compile-time type from the same schema
const r = User.safeParse(input); // no throw: {success:true,data} | {success:false,error}
if (r.success) use(r.data); // data: User
else report(r.error.issues); // [{path, message, code}, ...]
parse throws a ZodError; safeParse returns a discriminated result — prefer it at boundaries where one bad payload must not crash the process. Because z.infer reads the schema, adding a field updates validation, the type, and every call site in one edit.
Composition: Unions, Refine, Transform
Composition: Unions, Refine, Transform
const Event = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("created"), at: z.string().datetime() }),
z.object({ kind: z.literal("deleted"), reason: z.string().min(1) }),
]); // narrowed by the literal "kind"
const Slug = z.string()
.min(3).max(80)
.regex(/^[a-z0-9-]+$/, "lowercase kebab only")
.refine((s) => !s.endsWith("-"), { message: "no trailing dash" });
const Port = z.coerce.number().int().min(1).max(65535).default(3000);
const Trimmed = z.string().transform((s) => s.trim());
Pick z.union for ad-hoc alternatives (tries each) and z.discriminatedUnion when a shared literal tag exists — it fails fast on the tag instead of attempting every branch. transform maps after validation, refine/superRefine add cross-field rules that primitives cannot express.
Pitfalls and Interview Angles
Pitfalls and Interview Angles
- Unknown keys:
z.objectstrips unknown keys by default; strict rejects, loose passes through. Decide deliberately — dropping hides client bugs, accepting widens the surface. - Coercion boundaries: query strings and env vars arrive as strings — coerce at the boundary, never inside domain code where it masks earlier mistakes.
- Error reading: use
error.issueswithpathso the UI can highlightuser.email. - Performance: parsing is linear in payload size — reuse schema constants and cap payload size.
- Type drift: never hand-write
interface Useralongside the schema — one will rot. Define once,z.infereverywhere, and put the schema where the boundary is.
Interview angle: validate a request body, coerce the string port, narrow a discriminated event union, and explain stripping versus rejecting unknown keys.
Quiz
1. What does z.infer<typeof User> produce?
2. Default behavior of z.object for keys present in input but absent from the schema?
3. Why prefer z.discriminatedUnion over z.union here?
Flashcards
Question
z.infer
Click to reveal answer
Answer
Compile-time type extracted from a schema — runtime validation and static types from one definition.
Question
parse vs safeParse
Click to reveal answer
Answer
parse throws ZodError; safeParse returns {success, data | error} — use at untrusted boundaries.
Question
z.coerce
Click to reveal answer
Answer
Converts strings/numbers at the edge (env, query) before numeric validation rules run.
Question
Unknown-key modes
Click to reveal answer
Answer
Default strips unknown keys; strict rejects; loose passes them through.
Revision Notes
Key Takeaways
- 1. One schema drives runtime validation, inferred types, and error paths
- 2. discriminatedUnion + literal tags give TS narrowing on untrusted payloads
- 3. Decide strip vs strict vs loose for unknown keys, and coerce only at boundaries
Interview Tips
- • Show safeParse handling and issue.path-mapped error messages as your default boundary code
- • Explain why a hand-written interface beside a schema is a drift bug waiting to happen
Cheat Sheet
z.object().safeParse → {success,data|error}; type T = z.infer
Continue Learning
Before You Start
- No prerequisites listed.