Validation

Katal's schema-based validation runs automatically before handle() is called. If validation fails the controller is never invoked and a 422 Unprocessable Entity response (RFC 9457 Problem Details) is returned.

You can define validation in two places: - Controller-level schema (protected schema) inside a controller. - Route-level schema (options.validation) when registering a route.

When both are present, Katal merges them per request: - Route-level rules override controller-level rules for overlapping fields. - Non-overlapping fields from both schemas are included.

Basic Usage

import { Controller, ValidationSchema } from 'katal';

class CreateUserController extends Controller {
    // Define validation schema - validation happens automatically
    protected schema: ValidationSchema = {
        name: {
            required: true,
            type: 'string',
            minLength: 2
        },
        email: {
            required: true,
            type: 'email'
        },
        age: {
            type: 'number',
            min: 18
        }
    };

    async handle(context: RequestContext) {
        // At this point, validation has already passed
        // and context.body contains the validated data
        const { name, email, age } = context.body;

        // Create user with validated data
        const user = await this.createUser({ name, email, age });
        return this.success(user);
    }
}

How It Works

When a request is handled by a controller:

  1. The framework checks if the controller has a schema property
  2. The framework checks if the route has an options.validation schema
  3. If both exist, schemas are merged (route overrides controller on matching keys)
  4. The request body is validated against the effective schema
  5. If validation fails, a 422 Problem Details response is returned automatically
  6. If validation passes, the handle method is called with validated data in context.body

Route-level Validation (Registration-Time)

router.post('/users', CreateUserController, {
    validation: {
        email: { required: true, type: 'email' },
        password: { required: true, type: 'string', minLength: 8 }
    }
});

Merge Example

class CreateUserController extends Controller {
    protected schema: ValidationSchema = {
        email: { required: true, type: 'string' },
        age: { type: 'number', min: 13 }
    };
}

router.post('/users', CreateUserController, {
    validation: {
        // overrides controller email rule
        email: { required: true, type: 'email' },
        // adds an extra field
        name: { required: true, type: 'string' }
    }
});

Effective schema for this route includes: - email from route-level schema (override) - age from controller schema - name from route-level schema

Manual Validation

You can also validate data manually using the validateRequest method:

class CustomController extends Controller {
    async handle(context: RequestContext) {
        const schema: ValidationSchema = {
            id: { required: true, type: 'number' }
        };

        // Returns null if valid, or a Response with validation errors
        const validationError = this.validateRequest(context, schema);
        if (validationError) {
            return validationError;
        }

        // Validation passed
        const { id } = context.body;
        // ... continue processing
    }
}

Validation Types

ValidationRule Interface

interface ValidationRule {
    required?: boolean;
    type?: "string" | "number" | "boolean" | "email" | "url" | "array" | "object";
    min?: number;
    max?: number;
    minLength?: number;
    maxLength?: number;
    pattern?: RegExp;
    custom?: (value: any) => boolean | string;
    enum?: any[];
    schema?: ValidationSchema;
}

interface ValidationSchema {
    [key: string]: ValidationRule;
}

Basic Type Validation

const schema: ValidationSchema = {
    // String validation
    username: {
        type: 'string',
        required: true,
        minLength: 3
    },

    // Number validation
    age: {
        type: 'number',
        min: 18,
        max: 150
    },

    // Boolean validation
    active: {
        type: 'boolean'
    }
};

Special Types

const schema: ValidationSchema = {
    // Email validation (built-in)
    email: {
        type: 'email',
        required: true
    },

    // URL validation (built-in)
    website: {
        type: 'url'
    }
};

Pattern & Enum Validation

const schema: ValidationSchema = {
    // Regular expression pattern
    username: {
        type: 'string',
        pattern: /^[a-zA-Z0-9_]+$/
    },

    // Enum values
    role: {
        type: 'string',
        enum: ['admin', 'user', 'guest']
    }
};

Custom Validation

const schema: ValidationSchema = {
    password: {
        type: 'string',
        custom: (value) => {
            if (!/[A-Z]/.test(value)) {
                return 'Password must contain an uppercase letter';
            }
            if (!/[0-9]/.test(value)) {
                return 'Password must contain a number';
            }
            return true;
        }
    }
};

Nested Objects

const schema: ValidationSchema = {
    profile: {
        type: 'object',
        schema: {
            name: { required: true, type: 'string' },
            contact: {
                type: 'object',
                schema: {
                    email: { type: 'email' },
                    phone: { type: 'string', pattern: /^\+?[\d\s-]{10,}$/ }
                }
            }
        }
    }
};

Array Validation

const schema: ValidationSchema = {
    // Simple array
    tags: {
        type: 'array',
        minLength: 1
    },

    // Array with object schema
    items: {
        type: 'array',
        schema: {
            name: { required: true, type: 'string' },
            price: { type: 'number', min: 0 }
        }
    }
};

Error Handling

interface ValidationError {
    field: string;    // Field path (includes nested paths)
    message: string;  // Error message
}

// Example validation response
{
    "errors": [
        {
            "field": "email",
            "message": "email is required"
        },
        {
            "field": "profile.contact.phone",
            "message": "profile.contact.phone format is invalid"
        }
    ]
}

Best Practices

  1. Use Built-in Types When Available
// Prefer this:
const schema: ValidationSchema = {
    email: { type: 'email' }
};

// Over this:
const schema: ValidationSchema = {
    email: {
        type: 'string',
        pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    }
};
  1. Clear Error Messages
const schema: ValidationSchema = {
    age: {
        type: 'number',
        custom: (value) => {
            return value >= 21 || 'Must be 21 years or older';
        }
    }
};
  1. Type-Safe Schemas
interface CreateUserInput {
    username: string;
    email: string;
    profile: {
        firstName: string;
        lastName: string;
    };
}

const schema: ValidationSchema = {
    username: { required: true, type: 'string' },
    email: { required: true, type: 'email' },
    profile: {
        type: 'object',
        schema: {
            firstName: { required: true, type: 'string' },
            lastName: { required: true, type: 'string' }
        }
    }
} as const;

Validation Rule Reference

Rule Applies To Description
required all Field must be present and non-empty
type all "string", "number", "boolean", "email", "url", "array", "object"
min / max "number" Numeric range
minLength / maxLength "string", "array" Length range
pattern "string" RegExp match
enum any Value must be in the list
custom any Returns true or an error message string
schema "object", "array" Nested / array-item schema

See Also