Learning Goals
3 minBy the end of this lesson you will be able to:
- Explain in one sentence why you must never trust data sent by a client.
- Install Zod with
npm install zodand define a schema withz.object(...). - Check a request body with
schema.safeParse(req.body)and read thesuccessflag. - Reply 400 with helpful errors when data is bad, and use the clean
datawhen it is good.
Warm-Up · The Trusting Route
5 minLast lesson you designed clean resource routes. Now look at this POST route. It takes whatever arrives and stores it. Read it, then predict what goes wrong:
app.post('/api/students', (req, res) => {
const student = req.body
students.push(student)
res.status(201).json(student)
})Predict together
- What ends up in the array if the request has no
name? - What if
agearrives as the text"banana"instead of a number?
Reveal
The route trusts the body blindly. A request with no name stores a nameless student. A request with age: "banana" stores rubbish that will crash any code that tries to add or compare ages later. Worse, a sneaky client could send extra fields you never planned for. Bad input either poisons your data or crashes your app. We need a guard at the door.
New Concept · A Bouncer for Your Data
12 minPicture a bouncer at a club door. Every guest shows an ID card. If the card is real and the guest is old enough, they walk in. If not, they are turned away — politely, with a reason. Only valid guests reach the dance floor.
Zod is that bouncer for your API. You describe the shape you expect — the schema — and Zod checks every request body against it. Only data that passes reaches the rest of your route.
Validation — checking that incoming data has the right fields, of the right types, before you use it. A schema is the written rulebook Zod checks against.
The syntax box
Bring Zod in, describe the shape with z.object(...), then check a body with safeParse:
const { z } = require('zod')
const studentSchema = z.object({
name: z.string(),
age: z.number(),
})
const result = studentSchema.safeParse(req.body)
// result.success is true or falsesafeParse returns an object with three useful parts: success (a true/false flag), data (the clean, checked body when it passes), and error (the list of problems when it fails).
Pass and fail, side by side
When it passes
result.success is true.
Use result.data — the clean, trusted body.
Carry on and create the record.
When it fails
result.success is false.
Read result.error.issues for the reasons.
Reply 400 and stop right there.
Zod also gives you sharper rules than plain types. z.string().min(2) demands at least two letters, z.string().email() insists on a real email shape, and z.number().int() rejects half-numbers.
Why it matters
Every real backend validates its input. It stops crashes from missing fields, keeps your database clean and trustworthy, and gives honest clients a clear message when they get something wrong. One guard at the door saves a hundred bugs deeper in.
Worked Example · Guarding POST /api/students
12 minWe will fix the trusting route from the Warm-Up. Work in your server.js with Express already set up, and restart with node server.js after each change.
Step 1 — Install Zod
In the terminal, add the library to your project:
$ npm install zodnpm downloads Zod and records it in your package.json:
added 1 package in 2sStep 2 — Define a schema
Near the top of server.js, bring in Zod and describe a valid student. A name is a string of at least two letters; an age is a whole number of at least seven:
const { z } = require('zod')
const studentSchema = z.object({
name: z.string().min(2),
age: z.number().int().min(7),
})Step 3 — Check the body with safeParse
Inside the POST route, hand the incoming body to the schema. This is the bouncer checking the ID:
app.post('/api/students', (req, res) => {
const result = studentSchema.safeParse(req.body)
// decide what to do next based on result.success
})Step 4 — Reject the bad, keep the good
When the check fails, reply 400 with the list of problems. When it passes, build the record from the clean result.data and reply 201:
app.post('/api/students', (req, res) => {
const result = studentSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ errors: result.error.issues })
}
const student = { id: students.length + 1, ...result.data }
students.push(student)
res.status(201).json(student)
})Step 5 — Test a good body and a bad one
With the server running, send a valid student from a second terminal:
$ curl -X POST http://localhost:3000/api/students \
-H "Content-Type: application/json" \
-d '{ "name": "Aisyah", "age": 14 }'Zod is happy, so the server creates the student and replies:
{ "id": 4, "name": "Aisyah", "age": 14 }Now send an invalid body — no name, age as text:
$ curl -X POST http://localhost:3000/api/students \
-H "Content-Type: application/json" \
-d '{ "age": "banana" }'The bouncer turns it away with a 400 and the reasons:
{
"errors": [
{ "path": ["name"], "message": "Required" },
{ "path": ["age"], "message": "Expected number, received string" }
]
}What changed? The route no longer trusts the body. Bad requests bounce off with a clear 400, and only clean, checked data ever reaches your array.
Try It Yourself
13 minAdd validation to a second resource of your own — a dishes menu. Keep working in server.js, and test each change with curl from a second terminal.
Task 1 — Guard POST /api/dishes
Write a
dishSchemawithz.object(...)— anamestring and apricenumber. ThensafeParsethe body insidePOST /api/dishes, replying400on failure and201on success.const dishSchema = z.object({ name: z.string(), price: z.number(), })Task 2 — Add a sharper rule
Make the schema stricter. Give
namea.min(2), or add a new field likechef: z.string().email()for the chef's contact. Pick one rule and add it, then restart the server.Task 3 — Break it on purpose
Send a request that breaks your new rule — a one-letter name, or a
pricesent as text. Read the400reply and find your rule in themessage. Can you tell exactly which field upset Zod?
Mini-Challenge · The Sign-Up Guard
8 minBuild POST /api/signup for a new member. The body must carry a name (string, at least 2 letters), an email (a valid email), and an age (a whole number, at least 7). This joins today's Zod schema with the status codes from earlier.
It works if: a bad body returns 400 with the field errors, and a good body returns 201 with the new member.
Reveal a sample answer
First the schema, with one rule per field:
const signupSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().min(7),
})
app.post('/api/signup', (req, res) => {
const result = signupSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ errors: result.error.issues })
}
res.status(201).json({ member: result.data })
})Sending { "name": "A", "email": "nope", "age": 5 } returns a 400 naming all three problems:
{
"errors": [
{ "path": ["name"], "message": "String must contain at least 2 character(s)" },
{ "path": ["email"], "message": "Invalid email" },
{ "path": ["age"], "message": "Number must be greater than or equal to 7" }
]
}A valid body returns 201 with the member. Three rules, one guard, and every reply tells the client exactly what to fix.
Recap
3 min- Never trust the client — bad or missing fields crash apps or poison data.
- Zod is a validation library:
npm install zod, thenconst { z } = require('zod'). - Describe the shape with a schema:
z.object({ name: z.string(), age: z.number() }). - Check a body with
schema.safeParse(req.body)— it returnssuccess,data, anderror. - On failure reply 400 with
error.issues; on success use the cleandata.
New words
- Validation — checking incoming data is the right shape before you use it.
- Schema — the rulebook describing what a valid body looks like.
- Zod — a JavaScript library for defining schemas and validating data against them.
- safeParse — Zod's check that returns a result instead of crashing.
- 400 — the “Bad Request” status you send when validation fails.
Homework
4 min to brief · ~20 min to doRequired
- Pick one
POSTroute in your project and add Zod validation. Define a schema,safeParsethe body, and reply400on failure. - Send it a bad request with
curlor your browser tools, take a screenshot of the400reply and its errors, and bring it next lesson.
Optional stretch
- Zod also has
parse, not onlysafeParse. In two sentences, write the difference:parsethrows an error on bad data, whilesafeParsereturns a result you check. Which suits a web route better, and why?