Request Validation (Zod & express-validator)
Reviewed & published by Brayan K
Request validation is the practice of checking and cleaning incoming data on the server before you trust it, rejecting anything malformed with a clear 400 response.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll learn why server-side validation is non-negotiable, define schemas with Zod using parse and safeParse, add per-route rules with express-validator, return helpful 400 errors, and sanitize input.
What You'll Learn in This Lesson
- Why client-side checks can never be trusted on their own
- Defining a Zod schema and validating with parse
- Using safeParse to handle bad input without try/catch
- Adding express-validator rules with body() and validationResult()
- Returning a 400 with a clear list of field errors
- Sanitizing input — trimming, lowercasing, and coercing types
1️⃣ Schemas with Zod (parse)
A schema is a description of the data you expect. With Zod you build one with z.object(...) and chained refinements like .email(), .min(18), or .int(). Calling .parse(data) returns the clean, typed value — or throws a ZodError that lists every problem at once.
import { z } from 'zod';
// A schema describes the SHAPE you expect. Build it once, reuse it.
const SignupSchema = z.object({
email: z.string().email(),
age: z.number().int().min(18),
name: z.string().min(2),
});
// .parse() returns the typed, validated data — or THROWS if invalid.
const ok = SignupSchema.parse({ email: 'ada@dev.io', age: 36, name: 'Ada' });
console.log('valid:', ok);
try {
SignupSchema.parse({ email: 'nope', age: 15, name: 'A' });
} catch (err) {
// A ZodError lists EVERY problem at once, not just the first.
console.log('errors:', err.issues.length);
console.log('first:', err.issues[0].path.join('.'), '-', err.issues[0].message);
}2️⃣ Handling Failures with safeParse
In a request handler you usually don't want exceptions. schema.safeParse(data) never throws: it returns { success: true, data } or { success: false, error }. Check result.success, and on failure call error.flatten().fieldErrors to get a tidy { field: [messages] } object for your 400 response.
import { z } from 'zod';
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
// safeParse() NEVER throws. It returns { success, data } on success
// or { success, error } on failure — perfect for a request handler.
function handleLogin(body) {
const result = LoginSchema.safeParse(body);
if (!result.success) {
// flatten() gives a simple { field: [messages] } object for the response.
return { status: 400, errors: result.error.flatten().fieldErrors };
}
return { status: 200, user: result.data.email };
}
console.log(handleLogin({ email: 'ada@dev.io', password: 'supersecret' }));
console.log(handleLogin({ email: 'bad', password: '123' }));3️⃣ express-validator Middleware
If you're already in Express, express-validator attaches rules as middleware right on the route. Each body('field') describes a check (.isEmail(), .isLength({ min: 8 })) with a custom .withMessage(). In the handler, validationResult(req) gathers any failures so you can return a 400.
import express from 'express';
import { body, validationResult } from 'express-validator';
import request from 'supertest';
const app = express();
app.use(express.json());
app.post(
'/signup',
// Each body() call is a validation RULE that runs as middleware.
body('email').isEmail().withMessage('valid email required'),
body('password').isLength({ min: 8 }).withMessage('min 8 chars'),
(req, res) => {
// validationResult() collects whatever the rules flagged.
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array().map(e => e.msg) });
}
res.status(201).json({ created: req.body.email });
}
);
const ok = await request(app).post('/signup')
.send({ email: 'ada@dev.io', password: 'supersecret' });
console.log('valid ->', ok.status, JSON.stringify(ok.body));
const bad = await request(app).post('/signup')
.send({ email: 'nope', password: '123' });
console.log('invalid ->', bad.status, JSON.stringify(bad.body));Validation isn't only about rejecting — it's also about cleaning. Zod can trim whitespace, lowercase strings, and coerce a form's string into a real number, all while validating:
import { z } from 'zod';
// Zod can SANITIZE while it validates: trim, lowercase, and coerce types.
const Schema = z.object({
username: z.string().trim().toLowerCase().min(3),
// z.coerce.number() turns the string "36" from a form into a real number.
age: z.coerce.number().int().positive(),
});
const clean = Schema.parse({ username: ' AdaLovelace ', age: '36' });
console.log(clean);
console.log('typeof age:', typeof clean.age);Your turn. Fill in the three blanks marked ___ to finish the product schema, then run it and compare with the expected output.
import { z } from 'zod';
// 🎯 YOUR TURN — fill in the three ___ blanks, then run it.
const ProductSchema = z.object({
// 1) name must be a string with at least 1 character
name: z.string().___(1), // 👉 min
// 2) price must be a positive number
price: z.number().___(), // 👉 positive
});
// 3) Use the method that does NOT throw on bad input.
const result = ProductSchema.___({ name: 'Pen', price: 2.5 }); // 👉 safeParse
console.log('success:', result.success);
console.log('data:', result.data);
const bad = ProductSchema.safeParse({ name: '', price: -5 });
console.log('success:', bad.success);
console.log('issues:', bad.error.issues.length);
// ✅ Expected output:
// success: true
// data: { name: 'Pen', price: 2.5 }
// success: false
// issues: 2Mini-Challenge: Validate a Comment
No blanks this time — build the schema and validator yourself from the brief. Trim strings, cap the body length, and coerce the rating to a 1–5 integer. Run it and check your output against the example in the comments.
import { z } from 'zod';
// 🎯 MINI-CHALLENGE: validate a blog comment
// Build a CommentSchema that:
// - author: string, trimmed, at least 2 chars
// - body: string, trimmed, between 1 and 280 chars
// - rating: a number coerced from a string, integer, 1..5
// Then write validateComment(input) that returns:
// { ok: true, comment } when valid
// { ok: false, errors } when invalid (use flatten().fieldErrors)
//
// ✅ Example output:
// { ok: true, comment: { author: 'Sam', body: 'Great lesson!', rating: 5 } }
// { ok: false, errors: { author: [...], body: [...], rating: [...] } }
const CommentSchema = z.object({
// your fields here
});
function validateComment(input) {
// your code here
}
console.log(validateComment({ author: ' Sam ', body: 'Great lesson!', rating: '5' }));
console.log(validateComment({ author: 'S', body: '', rating: '9' }));Common Errors (and the fix)
- ❌ Your app crashes on bad input — ✅ You used parse() without a try/catch. In a handler, prefer safeParse() and branch on result.success.
- ❌ express-validator rules seem ignored — ✅ You forgot to read them. Validation rules only flag problems; you must call validationResult(req) in the handler and act on it.
- ❌ Numbers compare wrong (e.g. "18" < 18 is false) — ✅ Form/query values are strings. Coerce them with z.coerce.number() or .toInt() before comparing.
- ❌ req.body is undefined in express-validator — ✅ Add app.use(express.json()) first, otherwise there's no body to validate.
- ❌ You return 200 with an error message — ✅ Clients check status codes. Return res.status(400) with the field errors so failures are unambiguous.
Pro Tips
- 💡 Define each schema once and reuse it across routes — with Zod you also get a TypeScript type for free via z.infer.
- 💡 Validate at the edge — the very first thing a handler does — so the rest of your code can assume clean data.
- 💡 Return all errors at once so users fix their whole form in one go instead of one field per submit.
📋 Quick Reference — Validation
| Task | Syntax |
|---|---|
| Define a Zod schema | z.object({ email: z.string().email() }) |
| Validate (throws) | schema.parse(data) |
| Validate (no throw) | schema.safeParse(data) |
| Coerce a number | z.coerce.number() |
| express-validator rule | body('email').isEmail() |
| Read EV failures | validationResult(req) |
🎉 Lesson Complete!
- ✅ Server-side validation is the only trustworthy gate — client checks can be bypassed
- ✅ Zod's parse() throws on bad input; safeParse() returns a result object
- ✅ error.flatten().fieldErrors gives a clean map for a 400 response
- ✅ express-validator adds rules with body() and reads them with validationResult()
- ✅ Sanitize by trimming, lowercasing, and coercing strings to numbers
Practice quiz
Why is server-side validation non-negotiable?
- It makes forms look nicer
- Client-side checks can be bypassed by calling the API directly
- It is faster than the browser
- Browsers require it
Answer: Client-side checks can be bypassed by calling the API directly. Anyone can skip the form with curl or a script, so the server is the only trustworthy gate.
What does Zod's schema.parse(data) do on invalid input?
- Returns { success: false }
- Returns null
- Throws a ZodError
- Logs a warning and continues
Answer: Throws a ZodError. parse() returns the typed data on success or throws a ZodError on failure.
How does safeParse() report a failure?
- It returns { success: false, error }
- It throws an exception
- It returns undefined
- It exits the process
Answer: It returns { success: false, error }. safeParse never throws; it returns { success, data } or { success, error }.
What does error.flatten().fieldErrors give you?
- A stack trace
- The raw request body
- A list of valid fields
- A { field: [messages] } map for a 400 response
Answer: A { field: [messages] } map for a 400 response. flatten().fieldErrors produces a tidy per-field error object for responses.
In express-validator, how do you define a rule on a field?
- body('email').isEmail()
- validate('email')
- check.email()
- req.body.email.valid()
Answer: body('email').isEmail(). Each body('field') call attaches a validation rule as middleware.
How do you read express-validator failures in the handler?
- req.errors
- validationResult(req)
- res.errors()
- body.errors
Answer: validationResult(req). validationResult(req) collects whatever the rules flagged.
Form fields and query strings arrive as what type?
- Numbers
- Strings
- Booleans
- Objects
Answer: Strings. They are always strings, so '36' must be coerced before numeric comparison.
Which Zod helper turns the string '36' into the number 36?
- z.number()
- z.string().toNumber()
- z.coerce.number()
- z.cast.int()
Answer: z.coerce.number(). z.coerce.number() coerces the incoming string into a real number.
What HTTP status code fits an invalid request body?
- 200 OK
- 500 Internal Server Error
- 301 Moved
- 400 Bad Request
Answer: 400 Bad Request. Return 400 Bad Request with a list of which fields failed and why.
A ZodError reports how many of the problems found?
- Every problem at once
- Only the first one
- Only the last one
- None, just a boolean
Answer: Every problem at once. A ZodError lists every issue in err.issues so the user can fix the whole form.
Continue this course
- Previous: Express Routers & Route Organization
- Next: JWT Authentication in Express — Issue and verify JWTs, protect routes, and hash passwords
- Quick reference: Node.js cheat sheet
Frequently asked questions
Why validate input on the server if the form already validates in the browser?
Because client-side validation is a convenience, not a security boundary. Anyone can bypass your form by calling the API directly with curl, Postman, or a script — the browser's checks never run. Server-side validation is the only place you can actually trust, so it must reject malformed, missing, or malicious input before it reaches your database or business logic. Keep the browser checks for fast feedback, but treat the server as the real gatekeeper.
What's the difference between Zod's parse and safeParse?
Both validate against the same schema, but they report failure differently. parse() returns the validated data on success and THROWS a ZodError on failure, so you'd wrap it in try/catch. safeParse() never throws — it returns an object: { success: true, data } when valid, or { success: false, error } when not. In a request handler, safeParse() is usually cleaner because you can check result.success and return a 400 without exception handling.
Should I use Zod or express-validator?
Both are excellent; pick based on style. Zod defines a schema as a value you can reuse, infer TypeScript types from, and run anywhere (not just Express) — great when you want one source of truth for a shape. express-validator attaches rules as middleware directly on a route with body(), param(), and query(), which fits naturally into an existing Express pipeline and supports rich built-in sanitizers. Many teams use Zod for shared schemas and express-validator for quick per-route rules; you don't have to choose globally.
What HTTP status code should a validation failure return?
Return 400 Bad Request for malformed or invalid input, and include a body that lists which fields failed and why so the client can fix them. Reserve 422 Unprocessable Entity for cases where the syntax is fine but the data is semantically wrong (some APIs prefer it for validation); 400 is the safe, widely understood default. Never return 200 with an error message buried in the body — that breaks clients that rely on the status code.