Input Validation

Protect your API from bad data: validate request bodies, route parameters and query strings, return clear 400 errors, describe data with Zod schemas, coerce and default values, and reuse validation as Express middleware.

Lesson 14 of 43Intermediate17 min read

Every value that arrives at your server comes from outside: a browser form, a mobile app, another company's system, or someone deliberately sending nonsense. A price might arrive as "cheap", a quantity as -5, an email as an empty string, or a field might be missing entirely. If your code trusts that data, the results range from confusing errors and corrupted database records to serious security holes.

Validation means checking that incoming data has the shape you expect before you use it: the right fields, the right types, sensible lengths and ranges. When something is wrong, the API should reply with 400 Bad Request and a clear explanation of which field failed and why, so the client can fix it.

You can write validation by hand, and it's worth doing once to understand the job. Most projects then use a schema library, which describes the expected data once and checks it automatically. This lesson uses Zod, a popular choice that works with plain JavaScript and TypeScript alike. You'll validate bodies, route parameters and query strings, turn text into numbers safely, and package validation as reusable Express middleware.

What to check

  • Presence: required fields exist and aren't empty.
  • Type: numbers are numbers, booleans are booleans, arrays are arrays.
  • Format: emails, dates, URLs and IDs look right.
  • Range and length: quantities are positive, names aren't 10,000 characters long.
  • Allowed values: a status is one of pending, paid or shipped.
  • Unknown fields: extra properties such as isAdmin: true are ignored or rejected, never saved blindly.

Remember that route parameters and query strings are always strings, so /products/abc and ?page=-3 need checking just as much as JSON bodies. Validation in the browser improves the user experience, but it can be bypassed, so the server must always check again.

Zod schemas

JavaScript
import { z } from 'zod';

const NewProduct = z.object({
  name: z.string().trim().min(2).max(100),
  price: z.number().positive(),
  category: z.enum(['kitchen', 'home', 'office']),
  tags: z.array(z.string()).max(5).default([]),
});

const result = NewProduct.safeParse(req.body);
if (!result.success) return res.status(400).json({ errors: result.error.issues });
const product = result.data;    // clean, typed data
  • A schema describes the data; safeParse returns { success, data } or { success, error } without throwing. parse throws instead.
  • result.data contains only the fields in the schema, with defaults filled in and strings trimmed. Unknown keys are removed by default.
  • z.coerce.number() turns strings such as query parameters into numbers before checking them.
  • .optional(), .nullable() and .default(value) control missing values.
  • In TypeScript, z.infer<typeof NewProduct> gives you the matching type automatically.

Node.js Web Syntax

Zod essentials

import { z } from 'zod';
const Schema = z.object({
  email: z.email(),
  age: z.number().int().min(13).optional(),
  role: z.enum(['user', 'admin']).default('user'),
});
const r = Schema.safeParse(input);    // { success: true, data } or { success: false, error }
const data = Schema.parse(input);     // throws a ZodError if invalid
const Query = z.object({ page: z.coerce.number().int().min(1).default(1) });
const Params = z.object({ id: z.coerce.number().int().positive() });

Node.js Web Summary

  • Never trust request data; validate bodies, params and query strings on the server.
  • Reply with 400 and field-level messages when validation fails.
  • Zod schemas describe expected data; safeParse checks it without throwing.
  • z.coerce converts strings to numbers; defaults and optional handle missing fields.
  • Use the parsed data, which drops unknown keys, and reuse validation as middleware.

Node.js Web Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Validating by hand

Node.js
function validateOrder(body) {
  const errors = {};
  if (typeof body?.item !== 'string' || body.item.trim() === '') {
    errors.item = 'Item is required';
  }
  if (!Number.isInteger(body?.qty) || body.qty < 1 || body.qty > 20) {
    errors.qty = 'Quantity must be a whole number from 1 to 20';
  }
  if (body?.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(body.email)) {
    errors.email = 'Email looks invalid';
  }
  return errors;
}

const samples = [
  { item: 'Mug', qty: 2 },
  { item: '  ', qty: 0 },
  { item: 'Lamp', qty: '3', email: 'ada@' },
];

for (const s of samples) {
  const errors = validateOrder(s);
  console.log(Object.keys(errors).length === 0 ? 'OK' : JSON.stringify(errors));
}
Output
OK
{"item":"Item is required","qty":"Quantity must be a whole number from 1 to 20"}
{"qty":"Quantity must be a whole number from 1 to 20","email":"Email looks invalid"}
Line-by-line explanation
  1. Line 3Checking the type first catches missing values and wrong types together.
  2. Line 6Number.isInteger rejects the string "3", which a loose check would accept.
  3. Line 9The optional email is only checked when it's present.
  4. Line 12Errors are collected per field, so the client can show every problem at once.
Example 2

Zod schemas with safeParse

Node.js
import { z } from 'zod';

const NewProduct = z.object({
  name: z.string().trim().min(2, 'Name must be at least 2 characters'),
  price: z.number().positive('Price must be more than 0'),
  category: z.enum(['kitchen', 'home', 'office']),
  tags: z.array(z.string()).max(3).default([]),
});

const inputs = [
  { name: '  Desk lamp ', price: 24, category: 'home', isAdmin: true },
  { name: 'X', price: -1, category: 'garden' },
];

for (const input of inputs) {
  const result = NewProduct.safeParse(input);
  if (result.success) {
    console.log('Valid:', JSON.stringify(result.data));
  } else {
    for (const issue of result.error.issues) {
      console.log(`  ${issue.path.join('.')}: ${issue.message}`);
    }
  }
}
Output
Valid: {"name":"Desk lamp","price":24,"category":"home","tags":[]}
  name: Name must be at least 2 characters
  price: Price must be more than 0
  category: Invalid option: expected one of "kitchen"|"home"|"office"
Line-by-line explanation
  1. Lines 3 to 8The schema describes every field once, with custom messages where helpful.
  2. Line 4trim cleans the value before the length check.
  3. Line 7default fills in a missing tags field.
  4. Line 16safeParse never throws; it returns a result object.
  5. Line 18The parsed data is trimmed, defaulted and has the unknown isAdmin key removed.
  6. Lines 20 to 21Each issue names the field (path) and explains the problem.
Example 3

A reusable validation middleware

Node.js
import express from 'express';
import { z } from 'zod';

function validate(schemas) {
  return (req, res, next) => {
    const errors = [];
    for (const part of ['params', 'query', 'body']) {
      if (!schemas[part]) continue;
      const result = schemas[part].safeParse(req[part] ?? {});
      if (result.success) req.valid = { ...req.valid, [part]: result.data };
      else errors.push(...result.error.issues.map((i) => `${part}.${i.path.join('.')}: ${i.message}`));
    }
    if (errors.length) return res.status(400).json({ errors });
    next();
  };
}

const app = express();
app.use(express.json());

app.get('/products', validate({
  query: z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().max(50).default(10) }),
}), (req, res) => res.json(req.valid.query));

app.patch('/products/:id', validate({
  params: z.object({ id: z.coerce.number().int().positive() }),
  body: z.object({ price: z.number().positive() }),
}), (req, res) => res.json({ id: req.valid.params.id, ...req.valid.body }));

const server = app.listen(0, async () => {
  const base = `http://localhost:${server.address().port}`;
  console.log(await (await fetch(`${base}/products?page=3`)).text());
  console.log(await (await fetch(`${base}/products?limit=500`)).text());
  const patch = (id, body) => fetch(`${base}/products/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  console.log(await (await patch(7, { price: 9.99 })).text());
  const bad = await patch('abc', { price: 'cheap' });
  console.log(bad.status, (await bad.json()).errors.length);
  server.close();
});
Output
{"page":3,"limit":10}
{"errors":["query.limit: Too big: expected number to be <=50"]}
{"id":7,"price":9.99}
400 2
Line-by-line explanation
  1. Line 4validate is a middleware factory that takes schemas for each part of the request.
  2. Line 9Each part of the request is checked with its own schema.
  3. Line 10Valid, converted data is stored on req.valid for the handler.
  4. Line 13Any failure stops the request with 400 and every message.
  5. Line 22z.coerce turns the query strings into numbers before checking them.
  6. Line 26Route parameters are strings too, so the id is coerced and checked.

Common Node.js Web Mistakes

Trusting req.body directly

Don't
await db.users.update(req.params.id, req.body);   // a client can send { "role": "admin" }
Do
const changes = UpdateUser.parse(req.body);       // only the allowed fields survive
await db.users.update(req.params.id, changes);

Forgetting that query values are strings

Don't
const limit = req.query.limit ?? 20;
items.slice(0, limit + 5);                        // "10" + 5 is "105"
Do
const { limit } = z.object({ limit: z.coerce.number().int().max(100).default(20) }).parse(req.query);

Vague error messages

Don't
res.status(400).send('Invalid input');
Do
res.status(400).json({ errors: [{ field: 'email', message: 'Email looks invalid' }] });

Node.js Web in Real Projects

Validation in production

Teams define a schema for every endpoint and often share those schemas with the front end, so the same rules run in the browser form and on the server. Zod, Joi, Yup, Valibot and JSON Schema with Ajv are all widely used, and frameworks such as Fastify and NestJS validate requests automatically from schemas. Validation also feeds API documentation, because the schema already describes each request precisely.

Node.js Web FAQ

How do I reject unknown fields instead of silently removing them?

By default, z.object strips keys that aren't in the schema, which is safe but hides mistakes in client code. Use z.strictObject({ ... }) (or .strict() on an existing object schema) and Zod reports an unrecognized_keys issue instead. Rejecting is useful for public APIs where a misspelled field such as prise should produce an error rather than be ignored.

How do I return a separate error message for each field?

Each entry in result.error.issues has a path (such as ['email']) and a message, so you can map them to { field: issue.path.join('.'), message: issue.message }. Zod 4's z.flattenError(result.error) also groups the messages by field under fieldErrors. Front ends can then show each message next to the right input.

How do I validate an email address in Node.js?

Use a well-tested check rather than writing your own regular expression, for example z.email() in Zod 4 or z.string().email() in older versions. A format check only proves the address looks valid, not that it exists or belongs to the user. If ownership matters, send a confirmation link to the address.

What is the difference between validation and sanitisation?

Validation checks whether input is acceptable and rejects it if not, such as a negative quantity or an unknown category. Sanitisation changes input into a safe or standard form, such as trimming spaces, lowercasing an email or removing unknown keys. Most APIs do a little of both at the boundary; output escaping in HTML templates is a separate step that protects against XSS.

Should I use Zod, Joi or express-validator?

All three work well. Zod is popular in TypeScript projects because a schema also gives you a type with z.infer. Joi is a mature schema library with a similar style, and express-validator adds checks as Express middleware chained per field. Pick one for the whole project so every route reports errors in the same way.

Node.js Web Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the schema so price must be a number.

const Item = z.object({ price: z.().positive() });
Multiple choiceeasy

Which status code should an API return for invalid input?

Multiple choicemedium

What's the difference between parse and safeParse in Zod?

Predict the outputmedium

What does this print?

const S = z.object({ n: z.coerce.number() });
console.log(S.parse({ n: '42' }).n + 1);
Multiple choicehard

Why validate on the server when the browser form already validates?

Node.js Web Quiz

5 questions to check what you learned.

  1. What type are route parameters and query values in Express?

    • Whatever type they look like
    • Numbers
    • JSON
    • Strings
  2. What does a Zod object schema do with unknown keys by default?

    • Removes them from the parsed result
    • Rejects the data
    • Keeps them
    • Converts them to strings
  3. Which Zod method gives a value when a field is missing?

    • .optional()
    • .nullable()
    • .fill(value)
    • .default(value)
  4. Where should validation happen in an Express app?

    • Deep in the database code
    • At the edge, in the route or a validation middleware, before the data is used
    • Only in the browser
    • After saving
  5. Why is mass assignment, saving req.body directly, dangerous?

    • It's slow
    • JSON can't be saved
    • Clients can set fields they shouldn't, such as a role or a balance
    • It loses data