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

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: 2

Mini-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)

Pro Tips

📋 Quick Reference — Validation

TaskSyntax
Define a Zod schemaz.object({ email: z.string().email() })
Validate (throws)schema.parse(data)
Validate (no throw)schema.safeParse(data)
Coerce a numberz.coerce.number()
express-validator rulebody('email').isEmail()
Read EV failuresvalidationResult(req)

🎉 Lesson Complete!

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

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.