Runtime Validation with Zod
Reviewed & published by Brayan K
Zod is a schema validation library that checks data at runtime — exactly where TypeScript's compile-time types can't reach — and lets you derive a static type from each schema so your validator and your types never drift apart.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Build schemas with z.object, z.string, z.number, z.enum
- Choose between .parse() (throws) and .safeParse() (result)
- Derive a static type with z.infer<typeof Schema>
- Add custom rules with .refine, plus optional/nullable/default
- Coerce inputs and model unions and enums
- Validate API responses at the network boundary
💡 Real-World Analogy
A TypeScript type is a blueprint; Zod is the inspector at the door. The blueprint says what a valid visitor looks like, but it only exists on paper — once the building is open, anyone can walk up. Zod is the guard who actually checks each person against the blueprint and turns away anyone who doesn't match, so nothing malformed ever gets inside your program.
1. A Schema Is the Single Source of Truth
You describe the shape you expect once, with builders like z.object, z.string, and z.number. Then z.infer<typeof Schema> pulls a static TypeScript type straight out of that schema — so the validator and the type are always the same thing. This is real Zod syntax, so read it here rather than running it:
import { z } from "zod";
// A SCHEMA describes the SHAPE you expect at runtime.
const User = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(0).optional(), // may be missing
role: z.enum(["admin", "user"]), // exactly one of these
});
// Derive the STATIC TypeScript type straight from the schema:
type User = z.infer<typeof User>;
// = { id: number; name: string; email: string; age?: number; role: "admin" | "user" }
// One source of truth: the validator AND the type stay in sync.2. .parse() vs .safeParse()
Once you have a schema, you run data through it. .parse(value) returns the validated value or throws on bad data — great at a clear boundary with a try/catch. .safeParse(value) never throws: it hands back a result object ({ success, data | error }) so you can branch on failure. The box below uses a tiny stand-in for Zod so it runs anywhere.
// Zod is a RUNTIME validator. TypeScript types disappear once code runs,
// so Zod is what actually checks real values as your program executes.
// (Below we SIMULATE Zod with a tiny stand-in so the box runs anywhere.)
const z = {
object: (shape) => ({
parse(value) {
for (const key in shape) {
if (!shape[key](value[key])) throw new Error("Invalid field: " + key);
}
return value; // returns the validated, typed value
},
safeParse(value) {
try { return { success: true, data: this.parse(value) }; }
catch (e) { return { success: false, error: e.message }; }
},
}),
string: () => (v) => typeof v === "string",
number: () => (v) => typeof v === "number",
};
const User = z.object({ name: z.string(), age: z.number() });
// .parse() THROWS on bad data:
console.log(User.parse({ name: "Ada", age: 36 })); // { name: 'Ada', age: 36 }
// .safeParse() returns a result object instead of throwing:
console.log(User.safeParse({ name: "Bob", age: 40 })); // { success: true, data: {...} }
console.log(User.safeParse({ name: "Eve", age: "old" })); // { success: false, error: ... }3. Refinements, Optionals, Defaults & Coercion
Schemas chain modifiers: .optional() allows undefined, .nullable() allows null, .default(x) fills in a fallback, and z.coerce.number() converts before validating. For rules the built-ins can't express, .refine(fn, message) adds a custom predicate. We model each behaviour in plain JS below:
// Zod schemas chain extra rules. We model the key behaviours here in plain JS.
// .optional() -> value may be undefined
function optional(check) {
return (v) => v === undefined || check(v);
}
// .default(d) -> undefined becomes the fallback BEFORE validating
function withDefault(d) {
return (v) => (v === undefined ? d : v);
}
// z.coerce.number() -> convert first, then validate
function coerceNumber(v) {
return Number(v);
}
// .refine(fn) -> a CUSTOM rule on top of the basic shape
function refine(check, message) {
return (v) => { if (!check(v)) throw new Error(message); return v; };
}
const isString = (v) => typeof v === "string";
console.log(optional(isString)(undefined)); // true (allowed to be missing)
console.log(optional(isString)("hi")); // true
console.log(withDefault("guest")(undefined)); // guest
console.log(coerceNumber("42") + 1); // 43 ("42" coerced to 42)
const strongPassword = refine((s) => s.length >= 8, "Password too short");
console.log(strongPassword("supersecret")); // supersecret
try { strongPassword("abc"); } catch (e) { console.log(e.message); } // Password too shortYour turn. Fill in the two blanks marked ___ to finish a non-empty-string validator, then run it and check the output.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Finish a tiny validator that accepts only NON-EMPTY strings.
function validateName(value) {
// 1) It must be a string...
const isString = typeof value === ___; // 👉 the word "string" in quotes
// 2) ...and it must not be empty
if (isString && value.length > ___) { // 👉 use 0 so "" is rejected
return { success: true, data: value };
}
return { success: false, error: "Invalid name" };
}
console.log(validateName("Ada")); // { success: true, data: 'Ada' }
console.log(validateName("")); // { success: false, error: 'Invalid name' }
console.log(validateName(42)); // { success: false, error: 'Invalid name' }
// ✅ Expected output:
// { success: true, data: 'Ada' }
// { success: false, error: 'Invalid name' }
// { success: false, error: 'Invalid name' }4. Validating API Responses
This is where Zod earns its keep. A TypeScript interface for an API response is only a compile-time assumption — the server can still send something else. Run the response through a schema with .safeParse() and malformed data fails loudly at the boundary instead of silently corrupting state deep inside your app.
// THE killer use case: validate data crossing the network boundary.
// A TypeScript interface is only a compile-time PROMISE. The server can
// still send garbage. Zod checks the ACTUAL bytes that arrive.
// Stand-in safeParse for a Post = { id: number, title: string }
function parsePost(value) {
const okId = typeof value.id === "number";
const okTitle = typeof value.title === "string";
if (okId && okTitle) return { success: true, data: value };
return { success: false, error: "Response did not match Post schema" };
}
// A well-formed response:
console.log(parsePost({ id: 1, title: "Hello" }));
// { success: true, data: { id: 1, title: 'Hello' } }
// A broken response (id arrived as a string from a flaky API):
const result = parsePost({ id: "1", title: "Hello" });
if (result.success) {
console.log("Use it safely:", result.data.title);
} else {
console.log("Rejected at the boundary:", result.error);
}
// Rejected at the boundary: Response did not match Post schemaCommon Errors (and the fix)
- ❌ Calling .parse() without a try/catch on untrusted input — one bad value throws and can crash a request. ✅ Use .safeParse() and branch on result.success.
- ❌ Writing a separate interface and schema — they drift apart over time. ✅ Define the schema once and use z.infer<typeof Schema> for the type.
- ❌ Forgetting that a string field arrives as a string — query params and form values are text. ✅ Use z.coerce.number() to convert before validating.
- ❌ Trusting await res.json() because it's typed as your interface — that type is a lie until checked. ✅ Pass it through a schema first.
Pro Tips
- 💡 One schema, both jobs — derive the type with z.infer so validation and types can never disagree.
- 💡 Validate at the edges — API responses, form input, and env vars are the natural places to call .parse()/.safeParse().
- 💡 Use z.enum for fixed sets — it both validates and gives you a precise literal union type.
- 💡 .refine() for cross-field rules — anything comparing two fields (password match, date ranges) belongs in a refinement.
Mini-Challenge: A Settings Validator
No blanks this time — just a brief and an outline. Build the validator yourself (default theme, coerced volume, range check), run it, and match your output to the example in the comments.
// 🎯 MINI-CHALLENGE: a settings validator with a default
//
// Build validateSettings(input) that returns a SANITISED object:
// 1. theme -> must be "light" or "dark". If missing, DEFAULT to "light".
// 2. volume -> coerce to a number (use Number()), must be between 0 and 100.
// 3. If theme is an unknown string OR volume is out of range,
// return { success: false }.
// 4. Otherwise return { success: true, data: { theme, volume } }.
//
// Test inputs:
// { volume: "30" } -> { success: true, data: { theme: 'light', volume: 30 } }
// { theme: "dark", volume: "80" } -> { success: true, data: { theme: 'dark', volume: 80 } }
// { theme: "neon", volume: "10" } -> { success: false }
//
// ✅ Expected output:
// { success: true, data: { theme: 'light', volume: 30 } }
// { success: true, data: { theme: 'dark', volume: 80 } }
// { success: false }
// your code here🎉 Lesson Complete
- ✅ Zod validates data at runtime, where TypeScript types are already erased
- ✅ .parse() throws on bad data; .safeParse() returns a result object
- ✅ z.infer<typeof Schema> derives a static type from one schema
- ✅ .refine, optional, nullable, default, and coercion shape each field
- ✅ Validating API responses stops malformed data at the boundary
Practice quiz
What problem does Zod solve that TypeScript types alone cannot?
- It validates data at runtime, where TS types are already erased
- It makes the compiler faster
- It replaces interfaces entirely
- It adds new syntax to JavaScript
Answer: It validates data at runtime, where TS types are already erased. TypeScript types vanish at runtime. Zod validates real values as the program runs, catching bad data the compiler never sees.
Which Zod method throws an error when the value is invalid?
- .check()
- .safeParse()
- .parse()
- .validate()
Answer: .parse(). .parse(value) returns the typed value on success and throws a ZodError on failure.
What does .safeParse() return instead of throwing?
- undefined on failure
- A result object with success plus either data or error
- Always the parsed value
- A boolean only
Answer: A result object with success plus either data or error. .safeParse() returns { success: true, data } or { success: false, error }, so you handle failure without try/catch.
How do you derive a static TypeScript type from a Zod schema?
- Schema as Type
- typeof Schema
- Schema.toType()
- z.infer<typeof Schema>
Answer: z.infer<typeof Schema>. z.infer<typeof Schema> extracts the TypeScript type a schema describes, keeping types and validation in sync.
What does .refine() add to a schema?
- A custom validation rule beyond the basic shape
- A default value
- A type coercion step
- An optional flag
Answer: A custom validation rule beyond the basic shape. .refine(fn, message) attaches a custom predicate, letting you express rules like 'password and confirm must match'.
What does z.coerce.number() do with the input "42"?
- Leaves it as the string "42"
- Converts it to the number 42 before validating
- Returns NaN
- Rejects it because it is a string
Answer: Converts it to the number 42 before validating. z.coerce.number() runs Number() on the input first, so the string "42" becomes the number 42.
Which schema accepts a string OR the value undefined?
- z.string().nullable()
- z.string().default()
- z.string().required()
- z.string().optional()
Answer: z.string().optional(). .optional() allows the value to be undefined; .nullable() allows null instead.
What is the best way to model a field that is exactly "admin" or "user"?
- z.string()
- z.enum(["admin", "user"])
- z.any()
- z.literal("admin")
Answer: z.enum(["admin", "user"]). z.enum([...]) restricts a string field to a fixed set of literal values and infers the union type.
Why validate an API response with Zod even though you have a TypeScript interface for it?
- TypeScript cannot describe JSON
- Interfaces are slower than Zod
- The interface is only a compile-time promise; the network can return anything at runtime
- Zod replaces fetch
Answer: The interface is only a compile-time promise; the network can return anything at runtime. An interface is just a compile-time assumption. Zod actually checks the bytes that arrive, so malformed responses fail loudly instead of corrupting your app.
What does z.string().default("guest") produce when the input is undefined?
- null
- The string "guest"
- undefined
- A validation error
Answer: The string "guest". .default(value) supplies the fallback when the input is undefined, so a missing field becomes "guest".
Continue this course
- Previous: TypeScript Best Practices
- Next: Testing TypeScript — Write type-safe tests with Vitest/Jest, typed mocks, and async assertions
- Quick reference: TypeScript cheat sheet
Frequently asked questions
If TypeScript already checks my types, why do I need Zod?
TypeScript types are erased before your code runs, so they cannot check data that arrives at runtime: JSON from an API, form input, environment variables, or a message from another service. Zod validates those real values as the program executes. The two are complementary: TypeScript guards the code you write, Zod guards the data that comes in from outside.
What's the difference between .parse() and .safeParse()?
.parse(value) returns the validated, fully-typed value on success and THROWS a ZodError on failure, which suits a try/catch boundary. .safeParse(value) never throws: it returns { success: true, data } or { success: false, error }, which is ideal when invalid input is expected and you want to branch on it without exceptions.
How does z.infer keep my types and validation in sync?
Instead of writing an interface AND a separate validator, you write the schema once and derive the type with z.infer<typeof Schema>. Change the schema and the inferred type updates automatically, so there is a single source of truth and no risk of the validator and the type drifting apart.
When should I use .refine() instead of the built-in checks?
Use the built-ins (.min, .email, .int, and so on) for common, single-field rules. Reach for .refine() when you need a custom predicate, especially one that compares multiple fields, like confirming that 'password' equals 'confirmPassword' or that a start date is before an end date. .refine() takes a function plus an error message.