Input Validation
Every byte that comes from outside — bodies, query strings, headers — can be wrong or malicious. Validating it at the edge, with clear schemas, keeps bad data out of your services and database.
On this page · 6 min · Advanced
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Describe input with a Zod schema
- Coerce query strings into numbers and booleans
- Return 400 with field-level messages
- Build a reusable validate() middleware
Explanation#
Validation checks type, presence, format and ranges. Without it, "price": "free" or a 5 MB name reaches your database.
Zod (npm install zod) lets you declare a schema once: z.object({ name: z.string().trim().min(2), price: z.number().positive() }). schema.safeParse(data) returns { success, data } or { success: false, error }.
Query strings and params are strings; z.coerce.number() converts them. .default(...) fills missing optional values.
Return 400 with errors per field (error.flatten().fieldErrors) so front ends can show messages next to inputs.
Use the parsed result (data), not the raw input: unknown fields are stripped, values are trimmed and typed. A validate(schema, "body") middleware keeps routes clean.
Syntax#
import { z } from 'zod';
const ProductInput = z.object({
name: z.string().trim().min(2).max(100),
price: z.number().positive(),
});
const result = ProductInput.safeParse(req.body);
Code example: Validating bodies and query strings with a middleware#
import express from 'express';
import { z } from 'zod';
const ProductInput = z.object({
name: z.string().trim().min(2, 'Name must have at least 2 characters').max(100),
price: z.number({ invalid_type_error: 'Price must be a number' }).positive('Price must be greater than 0'),
tags: z.array(z.string()).max(5).default([]),
});
const ListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(10),
inStock: z.enum(['true', 'false']).transform(v => v === 'true').optional(),
});
const validate = (schema, source) => (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
return res.status(400).json({ error: 'Validation failed', fields: result.error.flatten().fieldErrors });
}
req.valid = result.data; // typed, trimmed, defaults applied, unknown keys removed
next();
};
const app = express();
app.use(express.json());
app.post('/api/products', validate(ProductInput, 'body'), (req, res) => res.status(201).json(req.valid));
app.get('/api/products', validate(ListQuery, 'query'), (req, res) => res.json(req.valid));
const server = app.listen(3023, async () => {
const base = 'http://localhost:3023/api/products';
const post = body => fetch(base, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
for (const r of [
await post({ name: ' Notebook ', price: 60, isAdmin: true }),
await post({ name: 'N', price: 'free' }),
await fetch(base + '?page=2&inStock=true'),
await fetch(base + '?pageSize=500'),
]) console.log(r.status, await r.text());
server.close();
});
201 {"name":"Notebook","price":60,"tags":[]}
400 {"error":"Validation failed","fields":{"name":["Name must have at least 2 characters"],"price":["Price must be a number"]}}
200 {"page":2,"pageSize":10,"inStock":true}
400 {"error":"Validation failed","fields":{"pageSize":["Number must be less than or equal to 50"]}}This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- ProductInput trims the name, checks lengths and requires a positive number price.
- Unknown fields like isAdmin are removed — no over-posting.
- ListQuery coerces query strings to numbers, applies defaults and turns "true" into true.
- validate() returns 400 with messages per field, or stores the clean data on req.valid.
- Handlers only ever see validated, typed data.
Real-world use case#
A signup API validates email format, password length and age with one Zod schema shared by the Express route and the React form, so both sides agree on the rules.
Common mistakes#
Attackers call the API directly. Validate on the server.
The raw body still contains unknown fields and untrimmed strings. Use the parsed data.
page=-5 or id=abc reach the database unless you validate them too.
Best practices#
- Schemas for body, query and params
- Use parsed data only
- Field-level 400 errors
- Limit sizes (strings, arrays, body)
Remember this#
Never trust input — validate at the edge.
z.coerce for query strings; .default for optional values.
safeParse → 400 with fieldErrors, or clean data.
Practice exercise#
Your task: Write a Zod schema for a signup body: email (valid email), password (8–64 chars), age (optional integer 13–120). Parse a sample with a bad email and print the field errors.
Need a hint?
Show solution
import { z } from 'zod';
const Signup = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8).max(64),
age: z.number().int().min(13).max(120).optional(),
});
const result = Signup.safeParse({ email: 'asha@', password: 'longenough' });
console.log(result.success); // false
console.log(result.error.flatten().fieldErrors); // { email: [ 'Enter a valid email' ] }
Mini quiz#
Which Zod helper converts "2" from a query string into the number 2?
Want more? Practise all Node.js MCQs · Node.js interview questions
My notes#
Saved in this browser.