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

💡 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 short

Your 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 schema

Common Errors (and the fix)

Pro Tips

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

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

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.