hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Validation for beginners with Zod

What is Zod

  • The problem with untyped data
  • Your first schema
  • Project setup

Primitive types

  • Strings
  • Numbers
  • Booleans, dates, and literals

Objects and arrays

  • Objects
  • Nested objects
  • Arrays
  • Unions and discriminated unions

Transforms and refinements

  • Transforms
  • Refinements
  • Preprocessing and coercion

Composing schemas

  • Pick, omit, and extend
  • Merge and intersection
  • Reusable schemas

Zod in practice

  • Validating API requests
  • Error formatting
  • Inferring TypeScript types
  • Cheatsheet and capstone

Your first schema

Describing the shape of data

In the last lesson, we saw that JSON.parse returns any and that trusting unvalidated data leads to runtime crashes. The fix is to describe what valid data looks like and then check incoming data against that description.

That description is called a schema. Think of it as a contract: “I expect an object with a name field that is a string and an email field that is also a string.” If the data matches the contract, great. If not, you get a clear error explaining exactly what went wrong.

Everything is a schema

The core idea in Zod is that everything is a schema. A schema is a runtime object that knows how to check a value and return a typed result.

z.string() is a schema. It checks that the input is a string.

z.number() is a schema. It checks that the input is a number.

z.object() is a schema that contains other schemas, one per key.

When you chain methods like .min(1) or .email() onto a schema, those are called refinements. A refinement adds an extra constraint without changing the base type. .min(1) refines a string schema to reject empty strings. .email() refines a string schema to only accept valid email addresses. We will use refinements constantly throughout this course.

This is the mental model for the entire library: build schemas, add refinements, compose them together.

Here is what that looks like in Zod:

Code along
import { z } from "zod/v4";

const ContactSchema = z.object({
  name: z.string(),
  email: z.string(),
});

Let’s walk through this piece by piece. z.object() is a schema that says “I expect an object.” Inside it, name: z.string() is a string schema that says “the name field must be a string.” And email: z.string() is another string schema for the email field. Every piece is a schema. The object schema contains two string schemas. That is the entire thing. A description of what valid data looks like.

Now, how do we actually check data against this schema?

parse: validate or throw

The simplest way is .parse():

Code along
const data = ContactSchema.parse({ name: "Alice", email: "[email protected]" });
// data is { name: "Alice", email: "[email protected]" }
// TypeScript knows: data.name is string, data.email is string

parse takes unknown data, validates it against the schema, and returns the validated result with correct TypeScript types. Notice that: TypeScript now knows data.name is a string. No more any.

But what happens if the data is invalid?

ContactSchema.parse({ name: 42, email: "" });
// Throws ZodError: [
//   { path: ["name"], message: "Expected string, received number" }
// ]

parse throws a ZodError. The error tells you exactly which field failed and why. name received a number, but the schema expected a string.

Throwing is fine in some situations, like validating environment variables at startup. If your database URL is missing, you want the app to crash immediately. But for user input? You probably do not want to throw. You want to catch the error and send back a nice 400 response.

safeParse: validate without throwing

That is what safeParse is for. It does the same validation but returns a result object instead of throwing:

const result = ContactSchema.safeParse({ name: "Alice", email: "[email protected]" });

if (result.success) {
  console.log(result.data); // { name: "Alice", email: "[email protected]" }
} else {
  console.log(result.error); // ZodError with details
}

result.success is either true or false. If it is true, result.data contains the validated data. If it is false, result.error contains the validation errors. No exceptions, no try-catch blocks, just a clean result you can inspect.

So when do you use which? Use parse when invalid data should crash your app (startup configuration, environment variables). Use safeParse when you want to handle errors yourself (user input, API requests). For most API work, safeParse is what you want.

Primitive schemas

Zod provides a schema for every JavaScript primitive type:

z.string(); // validates strings
z.number(); // validates numbers
z.boolean(); // validates booleans
z.null(); // validates null
z.undefined(); // validates undefined

Each one validates that the value is the correct type. Let’s try a few:

z.string().parse("hello"); // returns "hello"
z.string().parse(42); // throws ZodError

z.number().parse(42); // returns 42
z.number().parse("42"); // throws — "42" is a string, not a number

z.boolean().parse(true); // returns true
z.boolean().parse("true"); // throws — "true" is a string, not a boolean

Notice something important here: Zod is strict. "42" is not a number. "true" is not a boolean. The data must match the expected type exactly. This is intentional. If you need to convert strings to numbers (like when dealing with query parameters), there is a way to do that, and we will cover it in a later lesson on coercion.

Combining primitives into objects

You can put primitive schemas together to describe more complex shapes:

const BookSchema = z.object({
  title: z.string(),
  rating: z.number(),
  published: z.boolean(),
});

BookSchema.parse({
  title: "Kindred",
  rating: 4.5,
  published: true,
}); // returns the object with correct types

BookSchema.parse({
  title: "Kindred",
  rating: "4.5", // string, not number
  published: true,
}); // throws — rating must be a number

title must be a string. rating must be a number. published must be a boolean. If any field is the wrong type, validation fails and you get a clear error pointing to the exact field.

This is the foundation everything else builds on. In the next section, we will go deeper into each primitive type and learn about refinements like minimum length, valid email format, and integer-only numbers. But first, let’s set up the project we will be building throughout this course.

Exercises

Exercise 1: Create a schema for { username: string, age: number }. Parse valid and invalid data. Observe the errors.

Exercise 2: Use safeParse instead of parse. Check result.success and handle both cases.

Exercise 3: Try parsing { name: "Alice", extra: "field" } with the ContactSchema. Does it pass? (Yes, Zod strips extra fields by default.)

What is the difference between parse and safeParse?

← The problem with untyped data Project setup →

© 2026 hectoday. All rights reserved.