Free Ebook cover Express.js Beyond Basics: Middleware, Architecture, and Maintainability

Express.js Beyond Basics: Middleware, Architecture, and Maintainability

New course

13 pages

Request Validation and Data Sanitization for Express.js

Capítulo 7

Estimated reading time: 10 minutes

+ Exercise

Why a Dedicated Validation Layer Matters

Request validation is the gatekeeper between the outside world and your application logic. A dedicated validation layer should run before controllers, reject invalid input early (fail fast), and return meaningful, consistent error messages. This prevents controllers from becoming defensive and repetitive, reduces security risks (e.g., injection via unexpected types), and avoids subtle bugs caused by implicit type conversions.

In practice, validation should cover three input surfaces:

  • Params (e.g., /users/:userId)
  • Query (e.g., ?page=2&sort=-createdAt)
  • Body (e.g., JSON payloads for create/update)

To keep maintainability high, define schemas close to routes (so you can see what each endpoint expects) but keep them separate from controller logic. That means: routes reference schemas; controllers assume validated, sanitized, and coerced data.

Standardized Validation Error Format

Validation errors should be predictable for clients and easy to log/debug. A common pattern is a single error envelope with a list of field-level issues.

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      {
        "location": "body",
        "path": "email",
        "issue": "Invalid email format"
      },
      {
        "location": "query",
        "path": "page",
        "issue": "Expected a positive integer"
      }
    ]
  }
}

This format supports multiple failures at once (useful for forms), while still being “fail fast” at the controller boundary (controllers never run if validation fails).

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Schema-Based Validation with Coercion and Sanitization

A schema approach centralizes rules and enables reuse across endpoints. A widely used option is zod, which supports:

  • Coercion (e.g., query strings to numbers/booleans)
  • Sanitization (e.g., trimming strings, normalizing email)
  • Partial schemas (useful for PATCH)
  • Composability (extend/merge schemas)

Step 1: Install and Decide Where Schemas Live

Keep schemas near the router they serve, but in a dedicated folder to avoid mixing with controllers. Example layout:

src/
  routes/
    users/
      users.router.ts
      users.validation.ts
      users.controller.ts

This keeps validation rules discoverable without bloating route files.

Step 2: Define Reusable Schemas

Create small, reusable building blocks (IDs, pagination, email) and compose them into endpoint-specific schemas.

// src/routes/users/users.validation.ts
import { z } from "zod";

// Reusable primitives
export const UserIdParamSchema = z.object({
  userId: z.string().uuid("userId must be a valid UUID"),
});

export const PaginationQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  pageSize: z.coerce.number().int().positive().max(100).default(20),
});

export const EmailSchema = z
  .string()
  .trim()
  .toLowerCase()
  .email("Invalid email format");

// Endpoint-specific schemas
export const CreateUserBodySchema = z.object({
  email: EmailSchema,
  name: z.string().trim().min(1).max(100),
});

export const ListUsersQuerySchema = PaginationQuerySchema.extend({
  search: z.string().trim().min(1).max(100).optional(),
});

// PATCH: partial update (see dedicated section below)
export const UpdateUserBodySchema = CreateUserBodySchema.partial().refine(
  (obj) => Object.keys(obj).length > 0,
  { message: "At least one field must be provided" }
);

Notes:

  • z.coerce.number() converts query strings like "2" into 2.
  • trim() and toLowerCase() are simple sanitization steps that reduce inconsistent data.
  • .default() ensures controllers can rely on presence of pagination values.

Validation Middleware That Runs Before Controllers

The middleware should validate req.params, req.query, and req.body against schemas, then replace them with the parsed (sanitized/coerced) output. If validation fails, it should throw/return a standardized error object.

Step 3: Implement a Generic validateRequest Middleware

// src/shared/validation/validateRequest.ts
import { z, ZodError } from "zod";
import type { Request, Response, NextFunction } from "express";

type RequestSchemas = {
  params?: z.ZodTypeAny;
  query?: z.ZodTypeAny;
  body?: z.ZodTypeAny;
};

export function validateRequest(schemas: RequestSchemas) {
  return (req: Request, _res: Response, next: NextFunction) => {
    try {
      if (schemas.params) req.params = schemas.params.parse(req.params);
      if (schemas.query) req.query = schemas.query.parse(req.query);
      if (schemas.body) req.body = schemas.body.parse(req.body);
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        next(toValidationError(err));
        return;
      }
      next(err);
    }
  };
}

function toValidationError(err: ZodError) {
  return {
    name: "ValidationError",
    status: 400,
    code: "VALIDATION_ERROR",
    message: "Request validation failed",
    details: err.issues.map((issue) => ({
      location: guessLocation(issue.path),
      path: issue.path.join("."),
      issue: issue.message,
    })),
  };
}

function guessLocation(_path: (string | number)[]) {
  // If you want exact location, you can pass it in explicitly per schema.
  // This placeholder keeps the example focused.
  return "unknown" as const;
}

Two important behaviors here:

  • Fail fast before controllers: if parsing fails, the controller never runs.
  • Sanitize/coerce in one place: controllers receive already-normalized values.

In a real project, you typically want accurate location values. A maintainable approach is to validate each segment separately and tag the location explicitly (shown next).

Step 4: Improve Error Details with Explicit Locations

// src/shared/validation/validateRequest.ts
import { z, ZodError } from "zod";
import type { Request, Response, NextFunction } from "express";

type Location = "params" | "query" | "body";

type SegmentSchema = {
  location: Location;
  schema: z.ZodTypeAny;
};

export function validateRequestSegments(segments: SegmentSchema[]) {
  return (req: Request, _res: Response, next: NextFunction) => {
    const details: Array<{ location: Location; path: string; issue: string }> = [];

    for (const seg of segments) {
      try {
        const parsed = seg.schema.parse(req[seg.location]);
        (req as any)[seg.location] = parsed;
      } catch (err) {
        if (err instanceof ZodError) {
          for (const issue of err.issues) {
            details.push({
              location: seg.location,
              path: issue.path.join("."),
              issue: issue.message,
            });
          }
        } else {
          return next(err);
        }
      }
    }

    if (details.length > 0) {
      return next({
        name: "ValidationError",
        status: 400,
        code: "VALIDATION_ERROR",
        message: "Request validation failed",
        details,
      });
    }

    next();
  };
}

This version collects all issues across params/query/body and returns them in a single standardized response.

Keeping Validation Close to Routes (But Out of Controllers)

Routes should declare which schemas apply. Controllers should not re-check types or required fields; they should operate on validated inputs.

Step 5: Wire Validation into Routes

// src/routes/users/users.router.ts
import { Router } from "express";
import { validateRequestSegments } from "../../shared/validation/validateRequest";
import {
  UserIdParamSchema,
  CreateUserBodySchema,
  ListUsersQuerySchema,
  UpdateUserBodySchema,
} from "./users.validation";
import * as usersController from "./users.controller";

export const usersRouter = Router();

usersRouter.get(
  "/",
  validateRequestSegments([{ location: "query", schema: ListUsersQuerySchema }]),
  usersController.listUsers
);

usersRouter.post(
  "/",
  validateRequestSegments([{ location: "body", schema: CreateUserBodySchema }]),
  usersController.createUser
);

usersRouter.patch(
  "/:userId",
  validateRequestSegments([
    { location: "params", schema: UserIdParamSchema },
    { location: "body", schema: UpdateUserBodySchema },
  ]),
  usersController.updateUser
);

Maintainability benefits:

  • Each endpoint’s contract is visible at the route definition.
  • Schemas are reusable and testable in isolation.
  • Controllers remain focused on application behavior, not input policing.

Type Coercion and Sanitization Patterns That Prevent Bugs

Express receives most inputs as strings (especially query params). Relying on ad-hoc conversions inside controllers leads to inconsistent behavior across routers. Prefer schema-level coercion and normalization.

NeedSchema approachExample
Convert query string to numberz.coerce.number()page: z.coerce.number().int().positive()
Convert query string to booleanz.coerce.boolean() (or custom)includeDeleted: z.coerce.boolean().default(false)
Trim and normalize stringstrim(), toLowerCase()email: z.string().trim().toLowerCase().email()
Restrict allowed valuesz.enum([...])sort: z.enum(["createdAt", "name"]).optional()
Reject unknown keys.strict()z.object({...}).strict()

Strict Objects to Prevent “Silent Acceptance”

By default, some validators may allow extra keys. For APIs, silently accepting unknown fields can hide client bugs and create inconsistent behavior across endpoints. Use strict schemas where appropriate:

export const CreateUserBodySchema = z
  .object({
    email: EmailSchema,
    name: z.string().trim().min(1).max(100),
  })
  .strict();

This ensures { email, name, role: "admin" } fails validation instead of being ignored.

Reusing Schemas Across Endpoints Without Duplication

Schema reuse is where maintainability really improves. Instead of copying rules, compose them.

Compose with pick, omit, extend, and merge

// Base model-like schema (not a DB model; an API contract building block)
export const UserBaseSchema = z.object({
  email: EmailSchema,
  name: z.string().trim().min(1).max(100),
});

export const CreateUserBodySchema = UserBaseSchema.strict();

export const UpdateUserBodySchema = UserBaseSchema.partial().strict().refine(
  (obj) => Object.keys(obj).length > 0,
  { message: "At least one field must be provided" }
);

export const UserPublicResponseSchema = UserBaseSchema.extend({
  id: z.string().uuid(),
  createdAt: z.string().datetime(),
});

Even if you don’t validate responses at runtime, defining response schemas can help keep contracts consistent and can be used for tests.

Validating Partial Updates (PATCH) Without Inconsistency

PATCH endpoints are a common source of inconsistent validation: one router might allow empty bodies, another might treat missing fields as null, another might accept unknown keys. A consistent strategy prevents surprises.

Strategy A: partial() + “at least one field” rule

This is the most common approach for PATCH: all fields optional, but body must contain at least one allowed key.

export const UpdateUserBodySchema = z
  .object({
    email: EmailSchema.optional(),
    name: z.string().trim().min(1).max(100).optional(),
  })
  .strict()
  .refine((obj) => Object.keys(obj).length > 0, {
    message: "At least one field must be provided",
  });

Strategy B: Distinguish “unset” vs “set to null”

If your API allows explicit nulling (e.g., middleName: null), model it intentionally rather than letting it slip through.

export const UpdateProfileBodySchema = z
  .object({
    middleName: z.string().trim().min(1).max(100).nullable().optional(),
  })
  .strict();

This makes it clear which fields can be nulled and prevents accidental nulls from clients.

Strategy C: Patch operations array (advanced)

For complex resources, you can validate a list of operations (similar to JSON Patch). This reduces ambiguity but is more verbose for clients.

const PatchOpSchema = z.discriminatedUnion("op", [
  z.object({ op: z.literal("replace"), path: z.literal("/name"), value: z.string().trim().min(1) }),
  z.object({ op: z.literal("replace"), path: z.literal("/email"), value: EmailSchema }),
]);

export const PatchUserBodySchema = z.object({
  ops: z.array(PatchOpSchema).min(1),
}).strict();

Preventing Inconsistent Validation Across Routers

In larger codebases, inconsistency creeps in when each router invents its own validation style. The goal is to make the “right way” the easiest way.

Use a Single Validation Adapter

Expose one shared middleware factory (like validateRequestSegments) and require all routers to use it. This ensures:

  • Same error shape everywhere
  • Same coercion/sanitization behavior
  • Same strictness defaults (e.g., strict bodies)

Centralize Common Schemas

Keep cross-cutting schemas in a shared module to avoid duplication:

src/shared/schemas/
  ids.ts
  pagination.ts
  sorting.ts
  email.ts

Then routers import and compose. This prevents one router from validating pageSize as max 100 while another uses max 1000.

Enforce Conventions with Lightweight Checks

Two practical strategies:

  • Code review checklist: every route must declare validation middleware; controllers must not parse/coerce request inputs.
  • Tests for schemas: unit test critical schemas (especially PATCH) to lock behavior and prevent accidental loosening/tightening.

Practical Example: Validating Params + Query + Body Together

Consider an endpoint that updates a user and supports a query flag:

// PATCH /users/:userId?notify=true

const NotifyQuerySchema = z.object({
  notify: z.coerce.boolean().default(false),
});

usersRouter.patch(
  "/:userId",
  validateRequestSegments([
    { location: "params", schema: UserIdParamSchema },
    { location: "query", schema: NotifyQuerySchema },
    { location: "body", schema: UpdateUserBodySchema },
  ]),
  usersController.updateUser
);

Now the controller can safely assume:

  • req.params.userId is a valid UUID string
  • req.query.notify is a boolean (not "true")
  • req.body contains only allowed keys, sanitized, and not empty

Meaningful Messages: Field-Level and Cross-Field Rules

Good validation errors should point to the exact field and explain the rule. Schemas can also enforce cross-field constraints (e.g., “either email or phone must be provided”).

export const ContactBodySchema = z
  .object({
    email: EmailSchema.optional(),
    phone: z.string().trim().min(7).max(20).optional(),
  })
  .strict()
  .refine((obj) => obj.email || obj.phone, {
    message: "Either email or phone must be provided",
    path: [],
  });

Use cross-field rules sparingly and keep them close to the schema so the endpoint contract remains clear.

Now answer the exercise about the content:

In an Express.js app that uses schema-based request validation middleware, what is the primary benefit of parsing and replacing req.params, req.query, and req.body before the controller runs?

You are right! Congratulations, now go to the next page

You missed! Try again.

Running validation middleware before controllers fails fast and prevents defensive controller code. It also normalizes inputs (coercion/sanitization) and returns a predictable validation error format when parsing fails.

Next chapter

Security Hardening in Express.js: Headers, Input Safety, and Policies

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.