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:

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ 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:

  • stringSchema checks a primitive.
  • planSchema accepts one of two literal strings.
  • seatsSchema transforms text into a number.
  • signupSchema.parse(...) returns a new value whose seats field 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:

  1. Accept unknown.
  2. Parse with a runtime schema.
  3. Return typed data or a typed failure.
  4. 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

+50 XP on completion