Skip to main content
Backend & APIs
⚙️ Backend & APIsLesson 4 of 13

Request Validation & Error Handling

Never trust input. Validate on the server and return structured, correct errors.

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.

SituationCodeWhen to use it
Body / params / query failed schema422 Unprocessable EntitySyntactically valid JSON, semantically invalid data
Missing required header, malformed JSON400 Bad RequestThe request itself is broken before you even parse it
Not logged in401 UnauthorizedNo valid auth token present
Logged in but not allowed403 ForbiddenAuthenticated but lacks permission
Resource doesn't exist404 Not FoundAfter looking it up, it is genuinely gone
DB conflict (duplicate email)409 ConflictThe data was valid but violates a uniqueness rule
Something broke on your end500 Internal Server ErrorUnexpected 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.

  1. 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()).
  2. Write a validate(schema) middleware that runs safeParse, returns a 422 with details on failure, and attaches req.validatedBody on success. Replace every req.body reference in that route with req.validatedBody.
  3. Create AppError and a centralized errorHandler middleware. Register it as the last middleware in app.js. Throw an AppError(404, 'NOT_FOUND', ...) if your DB lookup returns null instead of the resource.
  4. Confirm that hitting the endpoint with missing fields returns a 422 with a details object — not a 500 and not a 200.
  5. 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 details object 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 in app.js handles all unhandled errors — there is no res.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. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.