Skip to content

Zod fundamentals

Three bugs so far, all with the same shape. A value arrived, the code used it, and nobody checked it first.

Fixing each one where it lands works, and it's endless. Every new destination is a new place to remember.

The other move is to check the value once, as it arrives, against a description of what you were expecting. That description is a schema, and Zod is the library this section uses to write one.

What a schema is

Picture the scanner at an airport. Your bag goes in one end. The machine has been set up with rules: nothing sharp, no liquids over a certain size. A bag that satisfies the rules comes out the other side unchanged. A bag that doesn't never makes it through.

A schema is the machine's settings. Parsing is running the bag through.

Install it and bring it in:

bash
npm install zod
js
import * as z from 'zod'

The simplest schema is a single rule about a single value:

js
const teacherSchema = z.string()

const teacher = 'Jonathan'

console.log(teacherSchema.parse(teacher))
// 'Jonathan'

parse hands the data to the machine. The data satisfies the rule, so it comes back out.

Give it something that doesn't fit and the machine stops:

js
teacherSchema.parse(12345)
// ZodError: Invalid input: expected string, received number

That's the whole model. Describe what's acceptable, run values through, get the value back or get an error.

JunoWhat a schema is Two words worth separating early, because they get used interchangeably and mean different things here.

The schema is the description of what you'll accept. Parsing is the act of checking something against it. You write the schema once and parse with it as often as you like.

JunoWhat a schema is Note that parse hands back the value, not a true or false. That's deliberate: it's a checkpoint you route data through, not a test you run beside it.

The habit that follows is to stop using the raw input after a parse and use the returned value instead. Same data today, and it means the parsed value is the one that flows onward once you start adding defaults and coercion.

JunoWhat a schema is The API is immutable, which is quiet enough to miss and produces a real bug. Every method returns a new schema instead of modifying the one it was called on, so schema.min(3) is a value you have to keep.

Call it and discard the result, and the original is unchanged with the constraint silently absent.

parse throws, which suits a boundary where failure should stop the request, and suits nothing where you want to inspect the failure. The alternative that returns a result object instead is the one you'll want in a form or a route handler, and it's covered in the next chapter.

Describing an object

A teacher is rarely one string. Give the schema a shape instead:

js
const teacherSchema = z.object({
  name: z.string(),
  age: z.number(),
})

const teacher = {
  name: 'Jonathan',
  age: 21,
}

console.log(teacherSchema.parse(teacher))
// { name: 'Jonathan', age: 21 }

Each entry inside z.object() is a key, and its value is the schema for that key. The machine now expects an object with a name that is any string and an age that is any number.

Break one of them and the error says exactly which:

js
teacherSchema.parse({ name: 'Jonathan', age: '21' })
// ZodError: Invalid input: expected number, received string

Zod ships the primitives you'd expect, z.string(), z.number(), z.boolean(), and objects nest inside objects as deeply as your data does.

JunoDescribing an object The nesting is the part that makes this scale. A schema for an object is built out of schemas for its fields, and each of those can itself be an object.

So the same idea covers a two-field form and a deeply structured API response. You're always describing one level at a time.

JunoDescribing an object Worth knowing before it surprises you: z.object() ignores keys you didn't describe. Parse { name: 'A', age: 1, extra: 'x' } against a schema describing only name and age, and you get back { name: 'A', age: 1 }, with no error and no extra.

That's usually what you want at a boundary, since it means an attacker cannot smuggle an extra field through into whatever you do with the parsed object. It also means a typo in a field name fails silently, so a value you expected to be there is quietly absent.

JunoDescribing an object When you want the stricter behaviour, z.strictObject() raises an error on unrecognised keys instead of dropping them. Verified on Zod 4.5.4: z.object() strips, z.strictObject() throws.

Which to reach for is a real decision, not a style preference. Stripping is the safer default for a public endpoint, because clients add fields and you don't want to break them.

Strict suits internal service-to-service calls, where an unexpected key usually means the two sides have drifted. Better to hear about that immediately than to debug a silently dropped field a week later.

The one place stripping bites is mass-assignment thinking. It protects the parsed object, and it does nothing for code that reaches past the parse and reads req.body directly. Parse once, then never look at the raw input again.

Why the check has to happen at runtime

TypeScript describes shapes too:

ts
type Teacher = {
  name: string
  age: number
}

That looks like the schema and does a completely different job. TypeScript checks types while you write code. When it compiles to JavaScript, every annotation is erased, so nothing survives into the running program.

Which is fine for values your own code produced, and useless for values it didn't. A request body, an API response, a form submission: those arrive while the program is running, long after the types stopped existing. TypeScript assumes your data is correct. Zod checks.

A schema is a description of a type that still exists when the data shows up.

JunoWhy the check has to happen at runtime This trips people up because both things look like they're checking the same thing, and only one of them is there when it matters.

TypeScript is a conversation with you while you write. The schema is a conversation with the data while the program runs.

JunoWhy the check has to happen at runtime Typing a request body is where this shows up most often. Casting it to a type you wrote, with req.body as SignupFields, compiles cleanly and checks nothing, because a type assertion is you telling the compiler to stop asking.

Every field you then read is an assumption. Parse the body with a schema instead and the assertion becomes a fact.

JunoWhy the check has to happen at runtime The boundary worth drawing is: anything that crossed a process boundary is untyped, whatever your annotations claim. Request bodies, query strings, environment variables, JSON read off disk, responses from a service your team owns, rows from a database whose migration you haven't run yet.

Third-party API responses are the one people leave out, on the grounds that the provider documents the shape. Providers ship changes, return partial objects during incidents, and add nulls to fields that were never null before. A schema at that edge turns a confusing failure deep inside your code into a clear one at the point of entry.

Zod is worth its size for this: no dependencies, and it runs the same in Node and the browser, so one schema can serve a route handler and the form that posts to it.

Try it

Write it from scratch, without copying the teacher example.

  1. Import Zod.
  2. Create a characterSchema with two keys: name, a string, and episode, a number.
  3. Define a character object with the name 'Luke Skywalker' and episode 4.
  4. Validate the character against the schema and log the result.
  5. Change episode to the string '4' and predict what happens before you run it.
Compare your answers
js
import * as z from 'zod'

const characterSchema = z.object({
  name: z.string(),
  episode: z.number(),
})

const character = {
  name: 'Luke Skywalker',
  episode: 4,
}

console.log(characterSchema.parse(character))
// { name: 'Luke Skywalker', episode: 4 }

Two keys means z.object() rather than a bare primitive, and each key gets its own schema.

With episode as '4', parse throws ZodError: Invalid input: expected number, received string. The string '4' is not a number, and Zod will not quietly convert it for you. Converting on purpose is a separate instruction, which the next chapter covers.

Episode 4 is correct, incidentally. Luke Skywalker first appears in the original 1977 Star Wars film, which was later numbered Episode IV.

Where this goes next

Right now a schema does one thing: it accepts a value or throws. That's enough to describe data and not yet enough to build with.

Inferring types and coercing input adds the two pieces that make it practical. One schema can hand TypeScript the type as well, so the shape is written once. And a failed parse can hand you a result to inspect instead of an exception to catch.