zod: one schema, the static type and the runtime guard
zod is schema-first validation: write one schema, derive the static type with `z.infer`, and `.parse`/`.safeParse` check bytes at runtime and narrow. The type and the guard come from one definition, so they can't drift — never hand-write both a type and a schema.
Last lesson ended on an unclosed gap: JSON.parse(...) as T is still a lie. You could hand-write a type guard — function isUser(x: unknown): x is User { return typeof x === "object" && x !== null && typeof (x as any).id === "number" && ... } — but now you maintain two things that must agree: the User interface and the guard. Add a field to the interface, forget the guard, and you are back to the 02:00 pager: the type says the field is there, the guard never checks it, the cast lies again. The fix is to make the type and the check the same artefact. That artefact is a zod schema.
One schema, two outputs
How do you stop the type and the guard from ever drifting? The answer is to produce both from a single definition — you edit one thing and both update atomically. That single thing is a zod schema.
A zod schema is a runtime value (an object with a .parse method) that also carries enough type information for z.infer to extract a static type. You write the shape once:
import { z } from "zod";
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
profile: z.object({ bio: z.string() }).nullable(),
});
type User = z.infer<typeof User>;
// ^? { id: number; name: string; email: string; profile: { bio: string } | null }The User type and the User schema are now derived from one definition. There is no second thing to keep in sync. Adding z.string() for a new field updates both the runtime check and the inferred type in one edit — drift is structurally impossible.
▸Why this works
This directly fixes the boundary lie from lesson 1. There, the type annotation was a promise the compiler couldn’t verify. Here, the same schema that produced the type also runs at runtime against the actual bytes — so the annotation is no longer a promise, it’s the output of a check. The type is true because the only way to obtain a typed User is to pass .parse.
.parse throws; .safeParse returns a discriminated union
Two ways to run the check at the boundary:
const res = await fetch("/api/user/42");
const json: unknown = await res.json(); // type it unknown, not any
// .parse — throws a ZodError on failure
const user = User.parse(json); // ^? User (or throws)
// .safeParse — returns a result you must narrow
const result = User.safeParse(json);
if (result.success) {
result.data; // ^? User — narrowed, validated
} else {
result.error; // ^? ZodError — paths + messages of every failure
}.safeParse returns exactly the discriminated union you built by hand in lesson 1 — { success: true; data: User } | { success: false; error: ZodError } — except now the data branch is earned by a real runtime check, not asserted. Use .parse at trust boundaries you want to fail loud (a server route validating its own DB read), and .safeParse where a bad value is an expected outcome you render (form input, third-party webhook).
Note z.infer returns the output type. With transforms the input and output differ; we’ll see that below.
Composing: nesting, unions, discriminated unions
Schemas compose like the types they mirror. Build big shapes from small ones, and model the success/error wire contract as a discriminatedUnion:
const ApiResponse = z.discriminatedUnion("status", [
z.object({ status: z.literal("ok"), data: User }),
z.object({ status: z.literal("error"), code: z.number(), message: z.string() }),
]);
type ApiResponse = z.infer<typeof ApiResponse>;
// ^? { status: "ok"; data: User } | { status: "error"; code: number; message: string }
const parsed = ApiResponse.parse(json);
if (parsed.status === "ok") parsed.data; // ^? Userz.discriminatedUnion is faster and produces better errors than a plain z.union because it reads the discriminant first and only validates the matching member — the runtime mirror of the discriminated-union narrowing you learned in unit 02.
Transforms and refinements: where input and output diverge
A schema can do more than check — it can reshape and enforce business rules:
const DateFromString = z.string().transform((s) => new Date(s));
type In = z.input<typeof DateFromString>; // ^? string
type Out = z.output<typeof DateFromString>; // ^? Date (z.infer === output)
const Password = z
.object({ pw: z.string().min(8), confirm: z.string() })
.refine((o) => o.pw === o.confirm, { message: "passwords must match", path: ["confirm"] });A transform means the value you parse in (a string) differs from the value you get out (a Date) — which is why z.infer is defined as the output type, and z.input exists for the pre-transform shape. A refine adds a predicate the static type can’t express (“these two fields are equal”) and attaches the failure to a field path for form display (this is exactly how you wire field-level errors in the Frontend track forms work).
Why is deriving `type User = z.infer<typeof User>` better than writing the `User` interface AND a zod schema separately?
Order the schema-first flow from definition to a typed, validated value:
- 1 Define the zod schema once (z.object, z.discriminatedUnion, refinements)
- 2 Derive the static type: type T = z.infer<typeof Schema>
- 3 At the boundary, type the raw input as unknown and call Schema.safeParse(input)
- 4 Narrow on result.success: data is the validated value typed as T; otherwise read result.error
- 01What does it mean that a zod schema is a 'single source of truth', and how does that fix the lesson-1 boundary lie?
- 02Contrast .parse and .safeParse, including the type each yields.
- 03Why does z.infer return the OUTPUT type, and where do transforms and refinements fit?
You now make the type and the runtime guard one artefact: define a zod schema, derive the type with z.infer, and .parse/.safeParse turn untrusted bytes into a narrowed, validated value. You compose schemas, model wire contracts with discriminatedUnion, and reshape with transforms and refinements. zod closes the boundary lie for one endpoint at a time. Next, tRPC scales the idea across the whole client/server boundary at once: instead of validating each response, the client infers its entire API type directly from the server’s router type — end-to-end type safety with no codegen, built on the inference machinery you’ve been learning. Now when you see a hand-written interface sitting next to a separate isUser guard, you know they are two things that will drift — and you know exactly how to merge them into one schema.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.