Node / Express Guide

Validation

Joi schema patterns for request validation.

All request inputs are validated with Joi before reaching service logic. Validation lives in src/validations/.

TL;DR — Validate in controllers with { convert: true }. commonSchemas provides reusable primitives (email, password, slug, date, time, dateRange, pagination with search/sort/order). Use mongoId() for every ObjectId. No Joi.extractType — type schemas manually.

Base Helpers — local.validation.ts

Reusable Joi primitives shared across schema files:

// src/validations/local.validation.ts
import Joi from "joi";

export const mongoId = (label = "ID") =>
  Joi.string()
    .hex()
    .length(24)
    .messages({
      "string.hex": `${label} must be a valid hexadecimal string.`,
      "string.length": `${label} must be exactly 24 characters long.`,
      "string.empty": `${label} cannot be empty.`,
      "any.required": `${label} is required.`,
    });

export const commonSchemas = {
  mongoId: mongoId(),

  slug: Joi.string()
    .trim()
    .lowercase()
    .min(3)
    .max(63)
    .pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
    .messages({ "string.pattern.base": "Invalid slug format." }),

  email: Joi.string().email().lowercase().trim().messages({
    "string.email": "Please provide a valid email address.",
  }),

  token: Joi.string().messages({
    "string.empty": "Token cannot be empty.",
    "any.required": "Token is required.",
  }),

  otp: Joi.string().messages({
    "string.empty": "OTP cannot be empty.",
    "any.required": "OTP is required.",
  }),

  password: Joi.string()
    .min(8)
    .max(128)
    .pattern(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#^()_+\-=\[\]{};':"\\|,.<>\/ ])/,
    )
    .messages({
      "string.min": "Password must be at least 8 characters long.",
      "string.max": "Password cannot exceed 128 characters.",
      "string.pattern.base":
        "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
    }),

  date: Joi.string()
    .pattern(/^\d{4}-\d{2}-\d{2}$/)
    .messages({ "string.pattern.base": "Date must be in YYYY-MM-DD format." }),

  time: Joi.string()
    .pattern(/^\d{2}:\d{2}$/)
    .messages({ "string.pattern.base": "Time must be in HH:MM format." }),

  dateRange: Joi.object({
    startDate: Joi.date().iso().required(),
    endDate: Joi.date().iso().min(Joi.ref("startDate")).required(),
  }),

  pagination: Joi.object({
    search: Joi.string().trim().max(100).optional().allow("", null),
    page: Joi.number().integer().min(1).max(1000).optional().default(1),
    limit: Joi.number().integer().min(1).max(100).optional().default(10),
    order: Joi.string().valid("asc", "desc").optional().default("desc"),
    sort: Joi.string().trim().max(50).optional().default("_id"),
  }),

  reason: Joi.string()
    .trim()
    .min(10)
    .max(500)
    .pattern(/^(?=.*[a-zA-Z0-9]).*$/)
    .optional(),

  name: Joi.string().min(1).max(100).trim(),
  phone: Joi.string().trim(),
};
The password regex requires lowercase + uppercase + digit + special character. The old docs only required uppercase + digit + special. Keep both the a-z and A-Z checks.

Schema Files

One schema file per controller/route. Each exports a Joi.ObjectSchema named <action>Schema:

// src/validations/auth.validation.ts
import Joi from "joi";
import { mongoId, commonSchemas } from "./local.validation";

export const loginSchema = Joi.object({
  email: commonSchemas.email.required(),
  password: Joi.string().min(1).required(),
  organizationId: mongoId("Organization ID").optional().allow(null, ""),
});

export const refreshTokenSchema = Joi.object({
  token: commonSchemas.token.required(),
});
Joi.extractType is not used in the actual codebase. Type your inputs manually or use Pick/interface types from the model files.

Extending Pagination

To add extra filters alongside pagination, use .keys():

export const getResourcesSchema = commonSchemas.pagination.keys({
  organizationId: mongoId("Organization ID").required(),
  status: Joi.string().valid("active", "inactive").optional(),
});

Using Schemas in Controllers

async function create(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction,
) {
  const method = "create";
  try {
    const { error, value } = createResourceSchema.validate(req.body, {
      convert: true,
    });
    if (error) return next(new UnprocessableEntityError(error.message));

    const result = await service.create(value);
    res.status(201).json(result);
  } catch (error: any) {
    log("error", resource, method, "Failed.", { error: error.message });
    return next(error);
  }
}

Rules

  • { convert: true } — the standard validation option. Joi coerces types (strings → numbers for pagination, etc.).
  • Use mongoId() for every ObjectId parameter — never trust raw string IDs.
  • No Joi.extractType — type your service inputs with manual TypeScript types or reuse model types.
  • Lowercase + trim email fields at the Joi level (commonSchemas.email).
  • Validate at the controller — not in the route file or the service.
  • .min(1) on update schemas — require at least one field to prevent empty PATCH requests.
  • commonSchemas.pagination includes search, order, and sort — not just page/limit.