Request Validation & Error Handling
Stage 3 · Backend & APIs · B.U.I.L.D. letter: L
Every production outage you will ever cause or prevent comes down to one question: did you trust data you shouldn't have?
⚠️ The vibe trap
You already have validation on your frontend — required fields, email regex, max lengths. That feels like enough, and for a while it is. Then someone sends a raw curl or a malformed JSON body directly to your API, your req.body.email is undefined, you pass it to your database layer, and your server throws a stack trace straight back to the client complete with file paths, table names, and the version of your ORM. Or you return 200 OK on a request that never did anything, leaving the caller with no idea it failed. Server-side validation is not defensive over-engineering — it is the only validation that counts.
🚪 The Bouncer Model: Never Trust the Client
The mental model that makes all of this click: your server is a bouncer at the door of a club. The browser's validation is the bouncer's earpiece — a courtesy check that filters out obvious mistakes before people queue up. But the bouncer at the door checks every ID personally, no matter what the earpiece said. Someone can bypass the earpiece entirely (Postman, curl, a bot). The door bouncer catches them anyway.
This is why you validate on the server even when your frontend already validates the same fields. The frontend is not part of your security surface. It is a client application running on hardware you do not control, in a process you cannot trust.
// ❌ Trusting the client
app.post('/api/users', async (req, res) => {
// "The frontend already checked this" — famous last words
const user = await db.users.create({ data: req.body });
res.json(user);
});
// ✅ The bouncer checks the ID
app.post('/api/users', async (req, res) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: 'VALIDATION_ERROR',
message: 'Request body failed validation.',
details: result.error.flatten().fieldErrors,
});
}
const user = await db.users.create({ data: result.data }); // only validated data touches the DB
res.json(user);
});
Common mistake: Passing req.body directly into a database call after a shallow null-check. A null-check only tells you the field exists — not that it is the right type, within bounds, or safe to use.
🔬 Schema Validation with Zod
Zod is the standard for TypeScript/JavaScript backend validation. You define the shape of what you accept once, and Zod both validates and narrows the type simultaneously. You get a clean data object with only the fields you declared — unknown extra fields are stripped automatically.
// schemas/user.schema.js
import { z } from 'zod';
export const createUserSchema = z.object({
email: z
.string({ required_error: 'Email is required.' })
.email('Must be a valid email address.'),
password: z
.string({ required_error: 'Password is required.' })
.min(8, 'Password must be at least 8 characters.'),
displayName: z.string().max(64).optional(),
role: z.enum(['student', 'instructor']).default('student'),
});
// z.infer gives you the TypeScript type for free
// export type CreateUserInput = z.infer<typeof createUserSchema>;
Use schema.safeParse(input) instead of schema.parse(input). parse throws on failure; safeParse returns { success: true, data } or { success: false, error } so you can handle the failure explicitly without a try/catch wrapping every single endpoint.
// validate query params and route params the same way
export const getUserParamsSchema = z.object({
id: z.string().uuid('User ID must be a valid UUID.'),
});
export const getUserQuerySchema = z.object({
includeDeleted: z
.enum(['true', 'false'])
.optional()
.transform((v) => v === 'true'), // query params are always strings; transform to boolean
});
Mental model: Your schema is a contract. If a caller cannot meet the contract, that is their problem — tell them clearly, then stop processing. You do not owe anyone a partial result.
Common mistake: Using Zod only for the request body and forgetting to validate req.params and req.query. Route params and query strings are user-controlled strings just like the body.
🚦 Status Codes: Saying Exactly What Went Wrong
HTTP status codes are a shared vocabulary between your API and every caller. Using 200 for errors, or 500 for bad input, destroys that vocabulary and makes every client guess what happened.
| Situation | Code | When to use it |
|---|---|---|
| Body / params / query failed schema | 422 Unprocessable Entity | Syntactically valid JSON, semantically invalid data |
| Missing required header, malformed JSON | 400 Bad Request | The request itself is broken before you even parse it |
| Not logged in | 401 Unauthorized | No valid auth token present |
| Logged in but not allowed | 403 Forbidden | Authenticated but lacks permission |
| Resource doesn't exist | 404 Not Found | After looking it up, it is genuinely gone |
| DB conflict (duplicate email) | 409 Conflict | The data was valid but violates a uniqueness rule |
| Something broke on your end | 500 Internal Server Error | Unexpected crash — never expected in normal flow |
The 400 vs 422 split is the one that trips people up most. Use 400 when the HTTP request itself is malformed (bad JSON, missing Content-Type). Use 422 when the JSON parsed fine but the values inside are wrong. Most frameworks default to 400 for parse errors automatically, so your Zod failures should return 422.
// middleware/validate.js
export function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: 'VALIDATION_ERROR',
message: 'One or more fields are invalid.',
details: result.error.flatten().fieldErrors,
});
}
req.validatedBody = result.data; // attach clean data; never use req.body again
next();
};
}
// usage: validation runs before your route handler, which never sees raw input
app.post('/api/users', validate(createUserSchema), createUserController);
Common mistake: Returning 200 with a JSON body like { "success": false } for validation errors. This breaks any HTTP client that checks status codes, and it breaks logging dashboards that graph 4xx rates.
🛡️ Centralized Error Handling: One Place to Rule Them All
Without a central error handler, you write a try/catch in every route, inconsistently format your error responses, and occasionally let an unhandled promise rejection crash the process. Centralizing means you write the catch logic once and every unhandled error in the app flows through it.
In Express, a centralized error handler is a middleware with four arguments — (err, req, res, next). Express knows it is an error handler because of that fourth parameter.
// middleware/errorHandler.js
// A tiny custom error class so you can throw structured errors from anywhere
export class AppError extends Error {
constructor(statusCode, code, message, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
export function errorHandler(err, req, res, next) {
// If it is already a structured AppError, use its fields directly
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.code,
message: err.message,
...(err.details && { details: err.details }),
});
}
// Log the real error internally (a real logger, not console.log)
console.error('[Unhandled Error]', err);
// Send a safe, generic response — never expose internals
return res.status(500).json({
error: 'INTERNAL_ERROR',
message: 'Something went wrong. Please try again later.',
// NO: err.message, NO: err.stack, NO: file paths, NO: SQL queries
});
}
// In app.js — register LAST, after all routes
app.use(errorHandler);
Now any route can throw new AppError(409, 'EMAIL_TAKEN', 'That email is already registered.') or next(err) and the response will be correctly formatted. No copy-pasted res.status(500).json(...) anywhere else.
Mental model: The error handler is the last bouncer in the chain — it catches anything that slipped through and decides what the outside world is allowed to know.
Common mistake: Registering the error handler before your routes. It only catches errors thrown after it is registered. It must be the last app.use() call.
🙈 Never Leak What You Know
When your server throws an unhandled error, the default behavior in many frameworks is to send the full stack trace back in the response. That stack trace contains:
- Absolute file paths (revealing your directory structure)
- The library versions you use (revealing which CVEs apply to you)
- Database table and column names (making SQL injection trivially easier)
- Sometimes fragments of the query or the data that caused the error
This is free intelligence for an attacker. The rule is simple: the outside world gets a safe generic message; your logs get the full truth.
// ❌ Leaking internals
app.get('/api/products/:id', async (req, res) => {
try {
const product = await db.products.findOrFail(req.params.id);
res.json(product);
} catch (err) {
res.status(500).json({ error: err.message, stack: err.stack }); // never do this
}
});
// ✅ Safe error surface
app.get('/api/products/:id', async (req, res, next) => {
try {
const { id } = getProductParamsSchema.parse(req.params); // throws ZodError on bad UUID
const product = await db.products.findUnique({ where: { id } });
if (!product) throw new AppError(404, 'NOT_FOUND', 'Product not found.');
res.json(product);
} catch (err) {
next(err); // hand off to the centralized handler — it decides what to send
}
});
In production, set NODE_ENV=production. Many libraries (including Express) suppress stack traces in error responses automatically when this is set. Do it anyway in your own error handler too — never rely on a library to make this decision for you.
Common mistake: Using console.error(err) in development is fine. Shipping console.error(err) to production means stack traces end up in your hosting provider's log dashboard, which may be visible to billing users, support staff, or leaked in a breach. Use a real logger (pino, winston) that redacts sensitive fields.
🛠️ Your Mission
Take one endpoint in your current app — ideally a POST or PUT that accepts a request body.
- Install Zod (
npm install zod) and write a schema for that endpoint's expected input. Cover every field: required vs optional, type, and at least one format constraint (e.g..min(),.email(),.uuid()). - Write a
validate(schema)middleware that runssafeParse, returns a 422 withdetailson failure, and attachesreq.validatedBodyon success. Replace everyreq.bodyreference in that route withreq.validatedBody. - Create
AppErrorand a centralizederrorHandlermiddleware. Register it as the last middleware inapp.js. Throw anAppError(404, 'NOT_FOUND', ...)if your DB lookup returns null instead of the resource. - Confirm that hitting the endpoint with missing fields returns a 422 with a
detailsobject — not a 500 and not a 200. - Confirm that a valid request still returns the correct 200/201 response unchanged.
✅ You're done when…
- Sending a request with a missing required field returns HTTP 422 (not 200, not 500) with a
detailsobject showing which fields failed and why (Production-Readiness Checklist). - A valid request still works correctly end-to-end — your validation rejects bad data but passes good data through without modification.
- Your server never sends a stack trace, file path, or raw database error message in any HTTP response body — check with devtools Network tab on an intentionally broken request (Security Audit Checklist).
- A single
errorHandler(err, req, res, next)middleware registered last inapp.jshandles all unhandled errors — there is nores.status(500)duplicated across individual route files (Security Audit Checklist).
➡️ Next: Business Logic & the Service Layer. Build It Right, Or Don't Build It At All. 🏛️