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.paginationincludessearch,order, andsort— not justpage/limit.
