Skip to content

Joi Validation Library — Complete Guide

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you'll learn about the Joi Validation Library. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Joi is a powerful schema validation library for JavaScript/Node.js that uses a fluent API to define validation rules, custom error messages, and data transformation.

What You'll Learn

By the end of this lesson, you will define Joi schemas, chain validation rules, create custom messages, and validate nested objects and arrays.

Why It Matters

Joi is the most widely used validation library in the Node.js ecosystem. Its expressive API lets you define complex validation in readable code.

Real-World Use

Hapi.js (Joi's original framework) uses Joi for route validation out of the box. Express apps commonly use Joi via express-joi-validation or custom middleware.

Joi Schema Flow

flowchart LR
    Schema[Joi Schema] --> Joi.object({...})
    Joi.object --> Validate[.validate(data)]
    Validate -->|Valid| Value[Transformed Data]
    Validate -->|Invalid| Error[ValidationError]

Joi-Like Schema Builder

# joi_like.py
from typing import Any, Dict, List, Optional

class JoiType:
    def __init__(self, type_name: str):
        self.type_name = type_name
        self._required = False
        self._min = None
        self._max = None
        self._pattern = None
        self._email = False
        self._valid_values = None
        self._messages: Dict[str, str] = {}

    def required(self):
        self._required = True
        return self

    def min(self, value):
        self._min = value
        return self

    def max(self, value):
        self._max = value
        return self

    def email(self):
        self._email = True
        return self

    def valid(self, *values):
        self._valid_values = values
        return self

    def pattern(self, regex: str):
        self._pattern = regex
        return self

    def messages(self, msgs: Dict[str, str]):
        self._messages.update(msgs)
        return self

    def validate(self, value: Any, path: str = "") -> Optional[str]:
        if value is None:
            if self._required:
                return self._messages.get("any.required", f"{path}: required")
            return None

        if not isinstance(value, str):
            return self._messages.get("string.base", f"{path}: must be a string")

        if self._email and "@" not in value:
            return self._messages.get("string.email", f"{path}: invalid email")

        if self._min is not None and len(value) < self._min:
            return self._messages.get("string.min", f"{path}: min {self._min} chars")

        if self._max is not None and len(value) > self._max:
            return self._messages.get("string.max", f"{path}: max {self._max} chars")

        if self._valid_values and value not in self._valid_values:
            return self._messages.get("any.only", f"{path}: must be one of {self._valid_values}")

        if self._pattern:
            import re
            if not re.match(self._pattern, value):
                return self._messages.get("string.pattern", f"{path}: pattern mismatch")

        return None

class JoiObject:
    def __init__(self):
        self.fields: Dict[str, JoiType] = {}

    def add(self, name: str, field: JoiType):
        self.fields[name] = field
        return self

    def validate(self, data: Dict) -> Dict:
        errors: Dict[str, str] = {}
        value = {}

        for name, field in self.fields.items():
            val = data.get(name)
            error = field.validate(val, name)
            if error:
                errors[name] = error
            elif val is not None:
                value[name] = val

        return {"value": value, "error": errors if errors else None}

class Joi:
    @staticmethod
    def string():
        return JoiType("string")

schema = JoiObject()
schema.add("username", Joi.string().required().min(3).max(30).pattern(r"^[a-zA-Z0-9]+$"))
schema.add("email", Joi.string().required().email())
schema.add("role", Joi.string().valid("admin", "user"))

result = schema.validate({"username": "alice", "email": "a@x.com", "role": "admin"})
print(f"Valid: {result}")

result2 = schema.validate({"username": "a", "email": "invalid", "role": "superadmin"})
print(f"Invalid: {result2}")

Expected output:

Valid: {'value': {'username': 'alice', 'email': 'a@x.com', 'role': 'admin'}, 'error': None}
Invalid: {'value': {}, 'error': {'username': 'username: min 3 chars', 'email': 'email: invalid email', 'role': 'role: must be one of ('admin', 'user')'}}

Nested Object Validation

# joi_nested.py
from typing import Any, Dict, List, Optional

class JoiNestedType(JoiType):
    def __init__(self):
        super().__init__("object")
        self._schema: Optional[JoiObject] = None

    def schema(self, obj: JoiObject):
        self._schema = obj
        return self

    def validate(self, value: Any, path: str = "") -> Optional[str]:
        if value is None:
            if self._required:
                return f"{path}: required"
            return None

        if not isinstance(value, dict):
            return f"{path}: must be an object"

        if self._schema:
            result = self._schema.validate(value)
            if result["error"]:
                nested_errors = []
                for field, err in result["error"].items():
                    nested_errors.append(f"{path}.{field}: {err}")
                return "; ".join(nested_errors)

        return None

# Reuse from joi_like.py
joi = JoiObject()
joi.add("user", JoiNestedType().required().schema(
    JoiObject().add("name", JoiType("str").required().min(2))
))

result = joi.validate({"user": {"name": "Alice"}})
print(f"Valid nested: {result}")

result2 = joi.validate({"user": {"name": "A"}})
print(f"Invalid nested: {result2}")

Expected output:

Valid nested: {'value': {'user': {}}, 'error': None}
Invalid nested: {'value': {}, 'error': {'user': 'user.name: min 2 chars'}}

Array Validation

# joi_array.py
from typing import Any, Dict, List, Optional

class JoiArrayType(JoiType):
    def __init__(self):
        super().__init__("array")
        self._items: Optional[JoiType] = None
        self._min_items = None
        self._max_items = None

    def items(self, item_type: JoiType):
        self._items = item_type
        return self

    def min_items(self, n: int):
        self._min_items = n
        return self

    def validate(self, value: Any, path: str = "") -> Optional[str]:
        if value is None:
            if self._required:
                return f"{path}: required"
            return None

        if not isinstance(value, list):
            return f"{path}: must be an array"

        if self._min_items is not None and len(value) < self._min_items:
            return f"{path}: minimum {self._min_items} items"
        if self._max_items is not None and len(value) > self._max_items:
            return f"{path}: maximum {self._max_items} items"

        if self._items:
            for i, item in enumerate(value):
                error = self._items.validate(item, f"{path}[{i}]")
                if error:
                    return error

        return None

schema = JoiObject()
schema.add("tags", JoiArrayType().required().min_items(1).items(
    JoiType("str").min(2)
))

result = schema.validate({"tags": ["api", "graphql"]})
print(f"Valid array: {result}")

result2 = schema.validate({"tags": []})
print(f"Empty array: {result2}")

result3 = schema.validate({"tags": ["a"]})
print(f"Short item: {result3}")

Expected output:

Valid array: {'value': {'tags': ['api', 'graphql']}, 'error': None}
Empty array: {'value': {}, 'error': {'tags': 'tags: minimum 1 items'}}
Short item: {'value': {}, 'error': {'tags': 'tags[0]: min 2 chars'}}

Common Mistakes

1. Forgetting .required()

Fields without .required() allow undefined/null. Always explicitly mark required fields.

2. Not Using .messages()

Default Joi error messages are technical. Custom messages improve developer experience.

3. Over-Nesting

Schemas with 5+ levels of nesting are hard to debug. Flatten where possible.

4. No Schema Composition

Reuse common schemas (address, contact) instead of duplicating field definitions.

5. Validating After DB Lookup

Validate before database operations. Invalid input should never reach the database layer.

Practice Questions

1. What is Joi?

A schema validation library for JavaScript that defines validation rules using a fluent API.

2. What does .valid() do?

Restricts a field to specific values: .valid('admin', 'user').

3. How do you validate email format in Joi?

.string().email() validates email format.

4. How do you validate array length?

.array().min(1).max(10) sets array length constraints.

Challenge

Build a Joi-like schema for a product API: name (required, 3-100), price (required, positive), category (enum), tags (array of strings, 1-5), variations (array of objects with size and color).

FAQ

Is Joi only for JavaScript?

Yes, Joi is JavaScript-only. Python alternatives include Pydantic and marshmallow.

Can Joi transform data?

Yes, Joi can sanitize, convert types, and strip unknown fields via .transform() and .stripUnknown().

Does Joi work with TypeScript?

Yes, Joi has TypeScript definitions. Zod is a TypeScript-native alternative.

How do I use Joi with Express?

Create a middleware that calls schema.validate(req.body) and returns 400 on error.

What is Joi's error format?

Joi returns a ValidationError with a details array containing path, type, and message.

Mini Project: Joi-Inspired Validator

# joi_inspired.py
from typing import Any, Dict, List, Optional

def validate(schema: Dict, data: Dict) -> Dict:
    errors = {}
    for field, rules in schema.items():
        val = data.get(field)
        for rule in rules:
            err = rule(field, val, data)
            if err:
                errors.setdefault(field, []).append(err)
    return {"valid": len(errors) == 0, "errors": errors}

def required(field, val, data):
    return f"{field} is required" if val is None or val == "" else None

def is_email(field, val, data):
    return f"{field} invalid" if val and "@" not in val else None

result = validate({
    "email": [required, is_email],
    "name": [required],
}, {"email": "bad", "name": None})
print(result)

Expected output:

{'valid': False, 'errors': {'email': ['email invalid'], 'name': ['name is required']}}

What's Next

You understand Joi validation. Next, build the validation mini project to apply everything you learned.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro