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/.

Base Helpers — local.validation.ts

Reusable Joi primitives shared across schema files:

// src/validations/local.validation.ts
import Joi from 'joi'

// MongoDB ObjectId validator
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 24 characters long.`,
    })

export const commonSchemas = {
  email: Joi.string().email().lowercase().trim().messages({
    'string.email': '"email" must be a valid email address.',
  }),

  password: Joi.string()
    .min(8)
    .max(128)
    .pattern(/^(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/)
    .messages({
      'string.min':            '"password" must be at least 8 characters.',
      'string.pattern.base':   '"password" must contain uppercase, a number, and a special character.',
    }),

  pagination: Joi.object({
    page:  Joi.number().integer().min(1).default(1),
    limit: Joi.number().integer().min(1).max(100).default(10),
  }),

  name:  Joi.string().min(1).max(100).trim(),
  phone: Joi.string().trim(),
}

Schema Files

One schema file per controller/route. Each exports a Joi.ObjectSchema named <action>Schema and an inferred TypeScript type:

// 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 registerSchema = Joi.object({
  firstName:      commonSchemas.name.required(),
  lastName:       commonSchemas.name.required(),
  email:          commonSchemas.email.required(),
  phone:          commonSchemas.phone.required(),
  password:       commonSchemas.password.required(),
  organizationId: mongoId('Organization ID').optional(),
})

export type TLoginInput    = Joi.extractType<typeof loginSchema>
export type TRegisterInput = Joi.extractType<typeof registerSchema>

Resource Validation Example

// src/validations/resource.validation.ts
import Joi from 'joi'
import { mongoId, commonSchemas } from './local.validation'

export const createResourceSchema = Joi.object({
  name:           commonSchemas.name.required(),
  description:    Joi.string().max(500).optional(),
  organizationId: mongoId('Organization ID').optional(),
})

export const updateResourceSchema = Joi.object({
  name:        commonSchemas.name.optional(),
  description: Joi.string().max(500).optional(),
}).min(1)   // require at least one field on updates

export type TCreateResourceInput = Joi.extractType<typeof createResourceSchema>
export type TUpdateResourceInput = Joi.extractType<typeof updateResourceSchema>

Using Schemas in Controllers

async function create(req, res, next) {
  const { error, value } = createResourceSchema.validate(req.body, { abortEarly: false })
  if (error) return next(new UnprocessableEntityError(error.message))

  const result = await service.create(value)   // value is fully typed
  res.status(201).json(result)
}

Rules

  • abortEarly: false — collect all validation errors, not just the first.
  • Use mongoId() for every ObjectId parameter — never trust raw string IDs.
  • Export inferred types (TLoginInput) from schema files so services are typed without duplicating shapes.
  • Lowercase + trim email fields at the Joi level — not in the service or model.
  • 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.