Schema Validation with Zod
Advanced · 20 min read · ▶ live playground · ✦ checkpoint
Schema validation is the ergonomic version of the boundary rule from 13.1. Instead of writing a fresh value is User guard by hand for every payload, you describe the runtime shape once and let a schema parse unknown input into typed output.
The important word is parse, not "validate." A validator that returns true still leaves you holding the original unknown value. A parser gives you the receipt: if it succeeds, the returned value is the type the rest of your program may trust.
Parse, don't validate
Project 2 used decoders: unknown came in, checked data came out. A schema library packages that idea into reusable pieces: string parser, number parser, object parser, union parser, transform parser.
The playground cannot import packages, so this is a tiny teaching version of the idea. It is not Zod. It is the boundary discipline in plain TypeScript:
→ Welcome ada@example.com on pro for 3 seats.
→ Next seat number: 4
→ Rejected at the boundary: Expected free or Expected pro
Notice what did real runtime work:
stringSchemachecks a primitive.planSchemaaccepts one of two literal strings.seatsSchematransforms text into a number.signupSchema.parse(...)returns a new value whoseseatsfield is really a number.
That last point is the parse-don't-validate rule. The schema did not merely approve the original object. It produced the value the rest of the program should use.
What real Zod looks like
A static Zod example — shown as read-only code because the lesson playground has no installed packages. In a real project, Zod is the package that supplies the schema builders:
import * as z from "zod";
const SignupSchema = z.object({
email: z.string(),
plan: z.union([z.literal("free"), z.literal("pro")]),
seats: z.string().transform((value) => Number(value)),
});
type Signup = z.infer<typeof SignupSchema>;
function parseSignup(input: unknown): Signup {
return SignupSchema.parse(input);
}Read the Zod version exactly like the playground:
z.object({ ... })means "the input must be an object with these fields."z.string()means "this field must be a string."z.union([...])means "try these accepted shapes.".transform(...)means "after the input parses, produce a different output value."z.infer<typeof SignupSchema>asks TypeScript for the output type of the schema.
With transforms, input and output can diverge. In the static example above, the input for seats is string-like form data, but the parsed Signup has seats: number. A transform is a conversion hook; production schemas still add whatever range, format, or business-rule checks the boundary needs. Real Zod also exposes z.input<typeof SignupSchema> and z.output<typeof SignupSchema> when you need to name both sides.
parse and safeParse
Zod's parse and safeParse mirror the two paths in the mini-schema.
Static Zod example:
import * as z from "zod";
const ApiUserSchema = z.object({
id: z.number(),
name: z.string(),
});
type ApiUser = z.infer<typeof ApiUserSchema>;
function readUser(body: unknown): ApiUser | string {
const result = ApiUserSchema.safeParse(body);
if (!result.success) {
return "Bad user payload";
}
return result.data;
}Use parse when a bad payload should stop the current operation immediately. Use safeParse when failure is part of the flow, such as showing form errors, returning a typed API-client result, or rejecting a webhook without throwing through the whole handler.
Either way, keep the boundary narrow: unknown input crosses into the schema, and only parsed output crosses into application code.
Where schemas belong
Schemas are for data your program did not create or cannot fully trust:
- Environment variables: parse at startup, then pass a typed config object through the app.
- API responses: parse response bodies before putting them in state, caches, or domain services.
- Form input: parse submitted strings into the shape your business logic expects.
Static Zod examples for common boundaries:
import * as z from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string(),
PORT: z.string().transform((value) => Number(value)),
});
const env = EnvSchema.parse(process.env);
const ContactFormSchema = z.object({
email: z.string(),
message: z.string(),
});
function parseContactForm(input: unknown) {
return ContactFormSchema.safeParse(input);
}This is the same line from 13.1, drawn with better tools: the network, storage, forms, and environment are outside the type system. A schema is the guard at the door.
The wider landscape
Zod is one package in a wider schema ecosystem. Valibot, ArkType, and libraries that implement Standard Schema all live in the same neighborhood: define a runtime parser, derive or connect a static type, and keep untrusted data from wandering through the app in a costume.
Do not memorize every library's syntax yet. Memorize the boundary habit:
- Accept
unknown. - Parse with a runtime schema.
- Return typed data or a typed failure.
- Let the rest of the program use only the parsed value.
Next lesson keeps the receipt after parsing. Schemas can prove that a value has the right shape; branded types and assertion functions help preserve stronger domain guarantees like "this string is a UserId, not just any string."
Checkpoint
Answer all three to mark this lesson complete