NexusCS

Joi

JavaScript
Joi is a powerful schema description language and data validator for JavaScript. Validate objects, arrays, strings, numbers and more with an expressive fluent API.
validation
schema
nodejs

Getting started

Installation

npm install joi

Basic usage

const Joi = require("joi");

const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(18),
});

const result = schema.validate({
  username: "john",
  email: "john@example.com",
  age: 25,
});

// result = { value: {...}, error: undefined }

Validation returns an object with value (coerced/validated data) and error (validation error or undefined).

Quick validation

const Joi = require("joi");

// Simple string validation
const { error, value } = Joi.string().min(5).validate("hello");

// With options
const result = Joi.number().validate("42", {
  convert: true, // Coerce string to number
});

// Throw on error
const value = Joi.attempt("test@example.com", Joi.string().email());

Schema types

String

Joi.string()
  .min(3)
  .max(100)
  .alphanum() // Only a-z, A-Z, 0-9
  .email() // Valid email
  .uri() // Valid URI
  .uuid() // Valid UUID
  .pattern(/^[A-Z]/) // Regex match
  .lowercase() // Convert to lowercase
  .uppercase() // Convert to uppercase
  .trim() // Remove whitespace
  .domain() // Valid domain name
  .hostname(); // Valid hostname

Number

Joi.number()
  .min(0)
  .max(100)
  .integer() // Whole numbers only
  .positive() // Greater than 0
  .negative() // Less than 0
  .multiple(5) // Divisible by 5
  .port() // Valid port (0-65535)
  .greater(10) // Strictly greater
  .less(100) // Strictly less
  .precision(2); // Max decimal places

Boolean

Joi.boolean()
  .truthy("Y", "yes", "1") // Custom true values
  .falsy("N", "no", "0") // Custom false values
  .sensitive(); // Don't expose in errors

Date

Joi.date()
  .min("1-1-2000") // Minimum date
  .max("now") // Maximum date
  .greater("now") // Must be future
  .less("12-31-2030") // Must be before
  .timestamp("unix") // Unix timestamp
  .iso(); // ISO 8601 format

Array

Joi.array()
  .items(Joi.string()) // Array of strings
  .min(1) // Min length
  .max(10) // Max length
  .length(5) // Exact length
  .unique() // No duplicates
  .sparse() // Allow undefined items
  .single(); // Convert single value to array

Object

Joi.object({
  name: Joi.string(),
  age: Joi.number(),
})
  .unknown(false) // No unknown keys
  .min(2) // Min key count
  .max(10) // Max key count
  .length(5); // Exact key count

Alternatives

// One of multiple types
Joi.alternatives().try(Joi.string(), Joi.number());

// Conditional alternatives
Joi.alternatives().conditional("type", {
  is: "admin",
  then: Joi.string().required(),
  otherwise: Joi.forbidden(),
});

Other types

Joi.any(); // Matches anything
Joi.binary(); // Buffer/binary data
Joi.function(); // Function type
Joi.symbol(); // Symbol type
Joi.link("#ref"); // Reference to another schema

Schema modifiers

Required and optional

Joi.string().required(); // Must be present
Joi.string().optional(); // May be absent
Joi.string().forbidden(); // Must not be present

Allow and valid

// Allow specific values
Joi.string().allow("", null);

// Only allow specific values (whitelist)
Joi.string().valid("red", "green", "blue");

// Disallow specific values (blacklist)
Joi.string().invalid("admin", "root");

Default values

// Static default
Joi.string().default("guest");

// Function default
Joi.date().default(Date.now, "current date");

// Context-based default
Joi.string().default(Joi.ref("$username"));

Metadata

Joi.string()
  .description("User full name")
  .example("John Doe")
  .note("Must match legal name")
  .tag("user", "profile")
  .meta({ internal: true });

Presence

// Change presence for all keys
Joi.object({
  name: Joi.string(),
  email: Joi.string(),
}).required(); // Both name and email required

// Override for specific keys
Joi.object({
  name: Joi.string().required(),
  email: Joi.string(),
}).optional(); // Name still required, email optional

Validation options

Common options

const options = {
  // Stop validation on first error
  abortEarly: false,

  // Allow unknown keys in objects
  allowUnknown: true,

  // Type coercion (string to number, etc)
  convert: true,

  // Remove unknown keys from result
  stripUnknown: true,

  // Default presence (required/optional/forbidden)
  presence: "required",
};

schema.validate(data, options);

Error options

const options = {
  // Custom error messages
  messages: {
    "string.min": "{{#label}} must be at least {{#limit}} characters",
    "string.email": "Please enter a valid email",
  },

  // Language for error messages
  errors: {
    wrap: {
      label: "`", // Wrap labels with backticks
    },
  },
};

Context and preferences

// Pass external values
const schema = Joi.string().valid(Joi.ref("$validValue"));
schema.validate("test", {
  context: { validValue: "test" },
});

// Preferences (alternative to options)
const schema = Joi.string().prefs({
  convert: false,
  abortEarly: false,
});

References and conditions

References

// Reference sibling field
Joi.object({
  password: Joi.string().required(),
  confirmPassword: Joi.string().valid(Joi.ref("password")).required().messages({
    "any.only": "Passwords must match",
  }),
});

// Reference parent field
Joi.object({
  level1: {
    level2: Joi.string().valid(Joi.ref("...parentField")),
  },
  parentField: Joi.string(),
});

// Reference context
Joi.string().valid(Joi.ref("$contextValue"));

Conditional validation

// Simple conditional
Joi.object({
  type: Joi.string().valid("user", "admin"),
  privileges: Joi.array().when("type", {
    is: "admin",
    then: Joi.required(),
    otherwise: Joi.forbidden(),
  }),
});

// Multiple conditions
Joi.number().when("type", {
  switch: [
    { is: "small", then: Joi.valid(1, 2, 3) },
    { is: "medium", then: Joi.valid(10, 20, 30) },
    { is: "large", then: Joi.valid(100, 200, 300) },
  ],
  otherwise: Joi.forbidden(),
});

// Conditional based on reference
Joi.object({
  a: Joi.number(),
  b: Joi.number().when("a", {
    is: Joi.number().greater(10),
    then: Joi.required(),
    otherwise: Joi.optional(),
  }),
});

Object validation

Keys and patterns

// Define object keys
Joi.object().keys({
  name: Joi.string(),
  age: Joi.number(),
});

// Unknown keys
Joi.object({
  name: Joi.string(),
}).unknown(true); // Allow any other keys

// Pattern matching keys
Joi.object().pattern(
  /^[A-Z]+$/, // Key pattern
  Joi.number().positive(), // Value schema
);

Dependencies

// Require both or neither
Joi.object({
  username: Joi.string(),
  password: Joi.string(),
}).and("username", "password");

// Forbid both
Joi.object({
  a: Joi.any(),
  b: Joi.any(),
}).nand("a", "b");

// Require at least one
Joi.object({
  email: Joi.string(),
  phone: Joi.string(),
}).or("email", "phone");

// Require exactly one
Joi.object({
  sms: Joi.string(),
  email: Joi.string(),
}).xor("sms", "email");

// If one present, require other
Joi.object({
  firstName: Joi.string(),
  lastName: Joi.string(),
}).with("firstName", "lastName");

// If one present, forbid other
Joi.object({
  a: Joi.any(),
  b: Joi.any(),
}).without("a", "b");

Object options

Joi.object({
  name: Joi.string(),
})
  .min(2) // Min number of keys
  .max(10) // Max number of keys
  .length(5) // Exact number of keys
  .unknown(false); // Forbid unknown keys

Array validation

Items validation

// Array of specific type
Joi.array().items(Joi.string());

// Multiple allowed types
Joi.array().items(Joi.string(), Joi.number());

// Required items in order
Joi.array().ordered(
  Joi.string().required(), // First item
  Joi.number().required(), // Second item
  Joi.boolean(), // Third item (optional)
);

// Mix ordered and unordered
Joi.array()
  .ordered(Joi.string()) // First must be string
  .items(Joi.number()); // Rest must be numbers

Array constraints

Joi.array()
  .min(1) // Min length
  .max(10) // Max length
  .length(5) // Exact length
  .unique() // No duplicates
  .sparse() // Allow undefined/null items
  .single() // Convert single value to array
  .has(Joi.string().valid("required")); // Must contain

Unique validation

// Unique by value
Joi.array().unique();

// Unique by property
Joi.array()
  .items(
    Joi.object({
      id: Joi.number(),
      name: Joi.string(),
    }),
  )
  .unique("id");

// Custom comparator
Joi.array().unique((a, b) => a.id === b.id);

Error handling

Checking errors

const { error, value } = schema.validate(data);

if (error) {
  console.log(error.message);
  console.log(error.details); // Array of error details

  error.details.forEach((detail) => {
    console.log(detail.message); // Error message
    console.log(detail.path); // Path to field (array)
    console.log(detail.type); // Error type
    console.log(detail.context); // Error context
  });
}

Custom messages

// Per-rule messages
Joi.string()
  .min(3)
  .message("Must be at least 3 characters")
  .email()
  .message("Must be a valid email");

// Multiple messages
Joi.string().messages({
  "string.min": "Too short (min {{#limit}})",
  "string.max": "Too long (max {{#limit}})",
  "string.email": "Invalid email format",
});

// Template variables
Joi.string().min(5).messages({
  "string.min": "{{#label}} must be at least {{#limit}} chars (got {{#value}})",
});

Validation methods

// Standard validation
const { error, value } = schema.validate(data);

// Throw on error
try {
  const value = Joi.attempt(data, schema);
} catch (error) {
  console.log(error.message);
}

// Async validation
const value = await schema.validateAsync(data);

// Assert (throws ValidationError)
Joi.assert(data, schema);

Examples

User registration

const userSchema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),

  email: Joi.string().email().required(),

  password: Joi.string()
    .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
    .min(8)
    .required()
    .messages({
      "string.pattern.base":
        "Password must contain uppercase, lowercase and number",
    }),

  confirmPassword: Joi.string().valid(Joi.ref("password")).required().messages({
    "any.only": "Passwords must match",
  }),

  birthDate: Joi.date().max("now").required(),

  agreeToTerms: Joi.boolean().valid(true).required(),
});

const { error, value } = userSchema.validate(userData);

API payload validation

const createPostSchema = Joi.object({
  title: Joi.string().min(5).max(100).required(),

  content: Joi.string().min(50).max(5000).required(),

  tags: Joi.array().items(Joi.string().lowercase()).min(1).max(5).unique(),

  published: Joi.boolean().default(false),

  publishedAt: Joi.date().when("published", {
    is: true,
    then: Joi.required(),
    otherwise: Joi.forbidden(),
  }),

  authorId: Joi.number().integer().positive().required(),
});

Nested object validation

const addressSchema = Joi.object({
  street: Joi.string().required(),
  city: Joi.string().required(),
  state: Joi.string().length(2).uppercase(),
  zipCode: Joi.string()
    .pattern(/^\d{5}(-\d{4})?$/)
    .required(),
});

const companySchema = Joi.object({
  name: Joi.string().required(),

  headquarters: addressSchema.required(),

  offices: Joi.array().items(addressSchema).min(1),

  employees: Joi.array().items(
    Joi.object({
      id: Joi.number().required(),
      name: Joi.string().required(),
      email: Joi.string().email().required(),
      department: Joi.string().valid("engineering", "sales", "support"),
      address: addressSchema,
    }),
  ),

  metadata: Joi.object().pattern(
    Joi.string(),
    Joi.alternatives().try(Joi.string(), Joi.number(), Joi.boolean()),
  ),
});

Express middleware

const express = require("express");
const Joi = require("joi");

// Validation middleware
const validate = (schema) => {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false,
      stripUnknown: true,
    });

    if (error) {
      const errors = error.details.map((detail) => ({
        field: detail.path.join("."),
        message: detail.message,
      }));

      return res.status(400).json({ errors });
    }

    req.validatedBody = value;
    next();
  };
};

// Usage
const app = express();

app.post("/users", validate(userSchema), (req, res) => {
  const userData = req.validatedBody;
  // Create user...
});

Query string validation

const querySchema = Joi.object({
  page: Joi.number().integer().min(1).default(1),

  limit: Joi.number().integer().min(1).max(100).default(20),

  sort: Joi.string()
    .valid("createdAt", "updatedAt", "name")
    .default("createdAt"),

  order: Joi.string().valid("asc", "desc").default("desc"),

  search: Joi.string().min(3).max(50).optional(),

  filter: Joi.object({
    status: Joi.string().valid("active", "inactive"),
    category: Joi.array().items(Joi.string()),
  }).optional(),
});

// Express usage
app.get("/api/posts", (req, res) => {
  const { error, value } = querySchema.validate(req.query, {
    convert: true,
    stripUnknown: true,
  });

  if (error) {
    return res.status(400).json({ error: error.message });
  }

  const { page, limit, sort, order, search, filter } = value;
  // Query posts...
});

Advanced patterns

Schema composition

// Base schema
const baseSchema = Joi.object({
  createdAt: Joi.date().default(Date.now),
  updatedAt: Joi.date().default(Date.now),
});

// Extend base schema
const userSchema = baseSchema.keys({
  username: Joi.string().required(),
  email: Joi.string().email().required(),
});

// Concat schemas
const fullSchema = Joi.object({
  id: Joi.number(),
}).concat(userSchema);

Custom validation

// Custom method
const customString = Joi.string().custom((value, helpers) => {
  if (value.includes("forbidden")) {
    return helpers.error("string.forbidden");
  }
  return value;
}, "custom validation");

// With custom error
Joi.string().custom((value, helpers) => {
  if (!isValidUsername(value)) {
    return helpers.message("Username is already taken");
  }
  return value;
});

Schema links

// Self-referencing schema
const schema = Joi.object({
  name: Joi.string(),
  children: Joi.array().items(Joi.link("#schema")),
}).id("schema");

// Validate tree structure
const tree = {
  name: "root",
  children: [
    { name: "child1", children: [] },
    { name: "child2", children: [{ name: "grandchild", children: [] }] },
  ],
};

External validation

// Async external validation
const schema = Joi.string().external(async (value) => {
  const exists = await checkDatabase(value);
  if (exists) {
    throw new Error("Value already exists");
  }
  return value;
});

// Must use validateAsync
const value = await schema.validateAsync("test");

Common gotchas

Type coercion

By default, Joi performs type coercion:

// String '42' becomes number 42
Joi.number().validate("42");
// { value: 42, error: undefined }

// Disable coercion
Joi.number().validate("42", { convert: false });
// { error: ValidationError }

// Disable at schema level
const schema = Joi.number().prefs({ convert: false });

Unknown keys

By default, unknown object keys are allowed:

const schema = Joi.object({
  name: Joi.string(),
});

schema.validate({ name: "John", age: 30 });
// { value: { name: 'John', age: 30 } }

// Forbid unknown keys
schema.validate(
  { name: "John", age: 30 },
  {
    allowUnknown: false,
  },
);
// { error: ValidationError }

// Strip unknown keys
schema.validate(
  { name: "John", age: 30 },
  {
    stripUnknown: true,
  },
);
// { value: { name: 'John' } }

Required vs presence

// Not the same!
Joi.string().required(); // Must be present
Joi.object().required(); // Object must exist

// Set default presence
const schema = Joi.object({
  name: Joi.string(),
  email: Joi.string(),
}).prefs({ presence: "required" });
// Both fields now required

// Override per field
const schema = Joi.object({
  name: Joi.string().required(),
  email: Joi.string().optional(),
}).prefs({ presence: "optional" });

Error handling

// Always check for errors
const { error, value } = schema.validate(data);
if (error) {
  // Handle error
}

// Or use try/catch with attempt
try {
  const value = Joi.attempt(data, schema);
} catch (error) {
  // Handle error
}

// Get all errors (not just first)
const { error } = schema.validate(data, {
  abortEarly: false,
});

Also see