Logo
⚠️ Unsaved
[M]:

Customizing Error Messages and Validation Patterns in Joi

Hey there! If you've been using Joi for validation, you've probably noticed that the default error messages can be a bit... technical. In this notebook, we'll explore how to customize error messages and create reusable validation patterns to make your validation more user-friendly and maintainable.

We'll cover:

  • Customizing individual error messages
  • Creating reusable error messages
  • Building custom validation patterns
  • Localizing error messages
  • Formatting error responses

Let's make those validation errors actually helpful!

[1]:
// Install Joi using npm
!npm install joi
$ npm install joi

added 6 packages in 2s

28 packages are looking for funding
  run `npm fund` for details
[2]:
// Import Joi
const Joi = require('joi');

// Check if Joi is loaded correctly
console.log("Joi version:", Joi.version, "\n");
Joi version: 17.13.3 
[M]:

1. Default vs. Custom Error Messages

Let's start by comparing default Joi error messages with customized ones.

[3]:
// Schema with default error messages
const defaultSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
birthdate: Joi.date().max('now').required()
});

// Invalid data
const invalidUser = {
username: 'a!', // Too short and has special character
email: 'not-an-email', // Invalid email
password: 'short', // Too short
birthdate: '2025-01-01' // Future date
};

// Validate with default error messages
const defaultResult = defaultSchema.validate(invalidUser, { abortEarly: false });
console.log("Default error messages:\n");
console.log(JSON.stringify(defaultResult.error.details, null, 2), "\n");
Default error messages:
[
  {
    "message": "\"username\" must only contain alpha-numeric characters",
    "path": [
      "username"
    ],
    "type": "string.alphanum",
    "context": {
      "label": "username",
      "value": "a!",
      "key": "username"
    }
  },
  {
    "message": "\"username\" length must be at least 3 characters long",
    "path": [
      "username"
    ],
    "type": "string.min",
    "context": {
      "limit": 3,
      "value": "a!",
      "label": "username",
      "key": "username"
    }
  },
  {
    "message": "\"email\" must be a valid email",
    "path": [
      "email"
    ],
    "type": "string.email",
    "context": {
      "value": "not-an-email",
      "invalids": [
        "not-an-email"
      ],
      "label": "email",
      "key": "email"
    }
  },
  {
    "message": "\"password\" length must be at least 8 characters long",
    "path": [
      "password"
    ],
    "type": "string.min",
    "context": {
      "limit": 8,
      "value": "short",
      "label": "password",
      "key": "password"
    }
  }
] 
[4]:
// Schema with custom error messages
const customSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required()
.messages({
'string.alphanum': 'Username must only contain letters and numbers',
'string.min': 'Username must be at least 3 characters long',
'string.max': 'Username cannot be longer than 30 characters',
'any.required': 'Username is required'
}),
email: Joi.string().email().required()
.messages({
'string.email': 'Please enter a valid email address',
'any.required': 'Email address is required'
}),
password: Joi.string().min(8).required()
.messages({
'string.min': 'Password must be at least 8 characters long',
'any.required': 'Password is required'
}),
birthdate: Joi.date().max('now').required()
.messages({
'date.max': 'Birthdate cannot be in the future',
'any.required': 'Birthdate is required'
})
});

// Validate with custom error messages
const customResult = customSchema.validate(invalidUser, { abortEarly: false });
console.log("Custom error messages:\n");
console.log(JSON.stringify(customResult.error.details, null, 2), "\n");
Custom error messages:
[
  {
    "message": "Username must only contain letters and numbers",
    "path": [
      "username"
    ],
    "type": "string.alphanum",
    "context": {
      "label": "username",
      "value": "a!",
      "key": "username"
    }
  },
  {
    "message": "Username must be at least 3 characters long",
    "path": [
      "username"
    ],
    "type": "string.min",
    "context": {
      "limit": 3,
      "value": "a!",
      "label": "username",
      "key": "username"
    }
  },
  {
    "message": "Please enter a valid email address",
    "path": [
      "email"
    ],
    "type": "string.email",
    "context": {
      "value": "not-an-email",
      "invalids": [
        "not-an-email"
      ],
      "label": "email",
      "key": "email"
    }
  },
  {
    "message": "Password must be at least 8 characters long",
    "path": [
      "password"
    ],
    "type": "string.min",
    "context": {
      "limit": 8,
      "value": "short",
      "label": "password",
      "key": "password"
    }
  }
] 
[M]:

2. Reusable Error Messages

Instead of repeating the same error messages across schemas, let's create reusable message templates.

[5]:
// Reusable error message templates
const errorMessages = {
string: {
min: '{#label} must be at least {#limit} characters long',
max: '{#label} cannot exceed {#limit} characters',
email: 'Please enter a valid email address',
alphanum: '{#label} must only contain letters and numbers',
pattern: '{#label} format is invalid'
},
number: {
min: '{#label} must be at least {#limit}',
max: '{#label} cannot exceed {#limit}',
integer: '{#label} must be a whole number'
},
date: {
min: '{#label} must be after {#limit}',
max: '{#label} must be before {#limit}'
},
any: {
required: '{#label} is required',
only: '{#label} contains invalid value'
}
};
[6]:
// Function to create a schema with reusable error messages
function createUserSchema() {
return Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
birthdate: Joi.date().max('now').required()
}).messages(errorMessages);
}

// Create schema with reusable messages
const reusableSchema = createUserSchema();

// Validate with reusable error messages
const reusableResult = reusableSchema.validate(invalidUser, { abortEarly: false });
console.log("Reusable error messages:\n");
console.log(JSON.stringify(reusableResult.error.details, null, 2), "\n");
Reusable error messages:
[
  {
    "message": "\"username\" must only contain alpha-numeric characters",
    "path": [
      "username"
    ],
    "type": "string.alphanum",
    "context": {
      "label": "username",
      "value": "a!",
      "key": "username"
    }
  },
  {
    "message": "\"username\" length must be at least 3 characters long",
    "path": [
      "username"
    ],
    "type": "string.min",
    "context": {
      "limit": 3,
      "value": "a!",
      "label": "username",
      "key": "username"
    }
  },
  {
    "message": "\"email\" must be a valid email",
    "path": [
      "email"
    ],
    "type": "string.email",
    "context": {
      "value": "not-an-email",
      "invalids": [
        "not-an-email"
      ],
      "label": "email",
      "key": "email"
    }
  },
  {
    "message": "\"password\" length must be at least 8 characters long",
    "path": [
      "password"
    ],
    "type": "string.min",
    "context": {
      "limit": 8,
      "value": "short",
      "label": "password",
      "key": "password"
    }
  }
] 
[M]:

3. Context-Specific Error Messages

Sometimes you need different error messages for the same validation rule in different contexts.

[7]:
// Schema with context-specific error messages
const signupSchema = Joi.object({
password: Joi.string().min(8).required().messages({
'string.min': 'For security, your password must be at least {#limit} characters',
'any.required': 'Please create a password for your account'
}),
passwordConfirm: Joi.string().valid(Joi.ref('password')).required().messages({
'any.only': 'Passwords do not match',
'any.required': 'Please confirm your password'
})
});

const loginSchema = Joi.object({
password: Joi.string().required().messages({
'any.required': 'Please enter your password to log in'
})
});

// Test signup validation
const signupData = {
password: 'short',
passwordConfirm: 'different'
};

console.log("Signup validation with context-specific messages:\n");
console.log(signupSchema.validate(signupData, { abortEarly: false }).error.details, "\n");
Signup validation with context-specific messages:
[
  {
    message: 'For security, your password must be at least 8 characters',
    path: [ 'password' ],
    type: 'string.min',
    context: {
      limit: 8,
      value: 'short',
      encoding: undefined,
      label: 'password',
      key: 'password'
    }
  },
  {
    message: 'Passwords do not match',
    path: [ 'passwordConfirm' ],
    type: 'any.only',
    context: {
      valids: [Array],
      label: 'passwordConfirm',
      value: 'different',
      key: 'passwordConfirm'
    }
  }
] 
[8]:
// Test login validation
const loginData = {};

console.log("Login validation with context-specific messages:\n");
console.log(loginSchema.validate(loginData).error.details, "\n");
Login validation with context-specific messages:
[
  {
    message: 'Please enter your password to log in',
    path: [ 'password' ],
    type: 'any.required',
    context: { label: 'password', key: 'password' }
  }
] 
[M]:

4. Custom Validation Patterns

Let's create some custom validation patterns with helpful error messages.

[9]:
// Password strength validation
const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;

const strongPasswordSchema = Joi.object({
password: Joi.string().pattern(passwordPattern).required().messages({
'string.pattern.base': 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character',
'any.required': 'Password is required'
})
});

// Test password strength validation
const weakPassword = { password: 'simple' };
console.log("Password strength validation:\n");
console.log(strongPasswordSchema.validate(weakPassword).error.details, "\n");
Password strength validation:
[
  {
    message: 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character',
    path: [ 'password' ],
    type: 'string.pattern.base',
    context: {
      name: undefined,
      regex: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
      value: 'simple',
      label: 'password',
      key: 'password'
    }
  }
] 
[10]:
// Phone number validation with custom format
const phoneSchema = Joi.object({
phone: Joi.string().pattern(/^\d{3}-\d{3}-\d{4}$/).required().messages({
'string.pattern.base': 'Phone number must be in format XXX-XXX-XXXX',
'any.required': 'Phone number is required'
})
});

// Test phone validation
const invalidPhone = { phone: '1234567890' };
console.log("Phone format validation:\n");
console.log(phoneSchema.validate(invalidPhone).error.details, "\n");
Phone format validation:
[
  {
    message: 'Phone number must be in format XXX-XXX-XXXX',
    path: [ 'phone' ],
    type: 'string.pattern.base',
    context: {
      name: undefined,
      regex: /^\d{3}-\d{3}-\d{4}$/,
      value: '1234567890',
      label: 'phone',
      key: 'phone'
    }
  }
] 
[M]:

5. Custom Validation Functions with Detailed Errors

Sometimes you need complex validation logic that goes beyond Joi's built-in validators.

[11]:
// Custom credit card validation
const paymentSchema = Joi.object({
cardNumber: Joi.string().required(),
expiryMonth: Joi.number().integer().min(1).max(12).required(),
expiryYear: Joi.number().integer().min(new Date().getFullYear()).required(),
cvv: Joi.string().length(3).pattern(/^\d{3}$/).required()
}).custom((value, helpers) => {
// Simple Luhn algorithm check for credit card validation
const cardNumber = value.cardNumber.replace(/\s/g, '');
// Check if expired
const currentDate = new Date();
const currentMonth = currentDate.getMonth() + 1; // JavaScript months are 0-indexed
const currentYear = currentDate.getFullYear();
if (value.expiryYear === currentYear && value.expiryMonth < currentMonth) {
return helpers.error('payment.expired', {
month: value.expiryMonth,
year: value.expiryYear
});
}
// Validate card number (simplified check)
if (!/^\d{16}$/.test(cardNumber)) {
return helpers.error('payment.invalidCardNumber');
}
return value;
}).messages({
'payment.expired': 'Your card has expired',
'payment.invalidCardNumber': 'Invalid card number',
'string.length': 'CVV must be 3 digits',
'number.min': '{#label} is invalid',
'number.max': '{#label} is invalid'
});
[12]:
// Test with expired card
const expiredCard = {
cardNumber: '4111111111111111',
expiryMonth: 1,
expiryYear: new Date().getFullYear(), // Current year
cvv: '123'
};

// Assuming current month is after January
if (new Date().getMonth() > 0) {
console.log("Expired card validation:\n");
console.log(paymentSchema.validate(expiredCard).error.details, "\n");
} else {
console.log("Cannot test expired card in January\n");
}
Expired card validation:
[
  {
    message: 'Your card has expired',
    path: [],
    type: 'payment.expired',
    context: { month: 1, year: 2025, label: 'value', value: [Object] }
  }
] 
[13]:
// Test with invalid card number
const invalidCard = {
cardNumber: '411111', // Too short
expiryMonth: 12,
expiryYear: new Date().getFullYear() + 1, // Next year
cvv: '123'
};

console.log("Invalid card number validation:\n");
console.log(paymentSchema.validate(invalidCard).error.details, "\n");
Invalid card number validation:
[
  {
    message: 'Invalid card number',
    path: [],
    type: 'payment.invalidCardNumber',
    context: { label: 'value', value: [Object] }
  }
] 
[M]:

6. Localizing Error Messages

If your application supports multiple languages, you'll want to localize error messages.

[14]:
// Error message templates for different languages
const errorMessageTemplates = {
en: {
required: '{#label} is required',
email: 'Please enter a valid email address',
min: '{#label} must be at least {#limit} characters',
max: '{#label} cannot exceed {#limit} characters'
},
es: {
required: '{#label} es obligatorio',
email: 'Por favor ingrese un correo electrónico válido',
min: '{#label} debe tener al menos {#limit} caracteres',
max: '{#label} no puede exceder {#limit} caracteres'
},
fr: {
required: '{#label} est requis',
email: 'Veuillez entrer une adresse e-mail valide',
min: '{#label} doit comporter au moins {#limit} caractères',
max: '{#label} ne peut pas dépasser {#limit} caractères'
}
};
[15]:
// Function to create a schema with localized error messages
function createLocalizedSchema(language = 'en') {
const messages = errorMessageTemplates[language] || errorMessageTemplates.en;
return Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required()
}).messages({
'any.required': messages.required,
'string.email': messages.email,
'string.min': messages.min,
'string.max': messages.max
});
}
[16]:
// Test with English error messages
const enSchema = createLocalizedSchema('en');
const invalidData = { name: 'A' };

console.log("English error messages:\n");
console.log(enSchema.validate(invalidData, { abortEarly: false }).error.details, "\n");
English error messages:
[
  {
    message: '"name" must be at least 2 characters',
    path: [ 'name' ],
    type: 'string.min',
    context: {
      limit: 2,
      value: 'A',
      encoding: undefined,
      label: 'name',
      key: 'name'
    }
  },
  {
    message: '"email" is required',
    path: [ 'email' ],
    type: 'any.required',
    context: { label: 'email', key: 'email' }
  }
] 
[17]:
// Test with Spanish error messages
const esSchema = createLocalizedSchema('es');

console.log("Spanish error messages:\n");
console.log(esSchema.validate(invalidData, { abortEarly: false }).error.details, "\n");
Spanish error messages:
[
  {
    message: '"name" debe tener al menos 2 caracteres',
    path: [ 'name' ],
    type: 'string.min',
    context: {
      limit: 2,
      value: 'A',
      encoding: undefined,
      label: 'name',
      key: 'name'
    }
  },
  {
    message: '"email" es obligatorio',
    path: [ 'email' ],
    type: 'any.required',
    context: { label: 'email', key: 'email' }
  }
] 
[18]:
// Test with French error messages
const frSchema = createLocalizedSchema('fr');

console.log("French error messages:\n");
console.log(frSchema.validate(invalidData, { abortEarly: false }).error.details, "\n");
French error messages:
[
  {
    message: '"name" doit comporter au moins 2 caractères',
    path: [ 'name' ],
    type: 'string.min',
    context: {
      limit: 2,
      value: 'A',
      encoding: undefined,
      label: 'name',
      key: 'name'
    }
  },
  {
    message: '"email" est requis',
    path: [ 'email' ],
    type: 'any.required',
    context: { label: 'email', key: 'email' }
  }
] 
[M]:

7. Formatting Error Responses

Finally, let's create a helper function to format error responses for API endpoints.

[19]:
// Helper function to format validation errors for API responses
function formatValidationErrors(error) {
if (!error) return null;
// Group errors by field
const errorsByField = {};
error.details.forEach(detail => {
const field = detail.path.join('.');
if (!errorsByField[field]) {
errorsByField[field] = [];
}
errorsByField[field].push(detail.message);
});
// Format for API response
return {
status: 'error',
message: 'Validation failed',
errors: Object.entries(errorsByField).map(([field, messages]) => ({
field,
messages
}))
};
}
[20]:
// Example API validation
const apiSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required()
.messages({
'string.alphanum': 'Username must only contain letters and numbers',
'string.min': 'Username must be at least 3 characters long',
'string.max': 'Username cannot be longer than 30 characters',
'any.required': 'Username is required'
}),
email: Joi.string().email().required()
.messages({
'string.email': 'Please enter a valid email address',
'any.required': 'Email address is required'
}),
age: Joi.number().integer().min(18).required()
.messages({
'number.base': 'Age must be a number',
'number.integer': 'Age must be a whole number',
'number.min': 'You must be at least 18 years old',
'any.required': 'Age is required'
})
});

// Simulate API request with invalid data
const apiRequest = {
username: 'a!',
email: 'not-an-email',
age: 16
};

// Validate and format errors
const validationResult = apiSchema.validate(apiRequest, { abortEarly: false });
const formattedErrors = formatValidationErrors(validationResult.error);

console.log("Formatted API validation errors:\n");
console.log(JSON.stringify(formattedErrors, null, 2), "\n");
Formatted API validation errors:
{
  "status": "error",
  "message": "Validation failed",
  "errors": [
    {
      "field": "username",
      "messages": [
        "Username must only contain letters and numbers",
        "Username must be at least 3 characters long"
      ]
    },
    {
      "field": "email",
      "messages": [
        "Please enter a valid email address"
      ]
    },
    {
      "field": "age",
      "messages": [
        "You must be at least 18 years old"
      ]
    }
  ]
} 
[M]:

Wrapping Up

We've covered a lot of ground on customizing error messages and validation patterns in Joi:

  • Creating user-friendly error messages
  • Building reusable message templates
  • Implementing context-specific validation messages
  • Developing custom validation patterns with clear errors
  • Adding complex validation with custom functions
  • Localizing error messages for international users
  • Formatting error responses for APIs

With these techniques, you can make your validation not just functional but actually helpful to your users. Good validation isn't just about rejecting bad data—it's about guiding users to provide the right data.

Sign in to save your work and access it from anywhere