πŸ“¦
Bunty

Validation

Bunty provides built-in validation for HTTP requests, environment variables, and runtime data using a powerful schema system from @bunty/common.

Request Validation

Validate incoming HTTP requests automatically:

import { createApp } from '@bunty/http';
import { t } from '@bunty/common';

const app = createApp();

app.route('/api/users')
    .post(createUser)
    .validate({
        body: {
            email: t.String().Email().Required(),
            password: t.String().MinLength(8).Required(),
            age: t.Number().Min(18).Optional(),
        },
        query: {
            invite: t.String().UUID().Optional(),
        },
        headers: {
            'x-api-key': t.String().Required(),
        }
    });

function createUser(req, res) {
    // req.body is validated and typed!
    const { email, password, age } = req.body;
    // ...
}

Validation Options

Body Validation

app.route('/api/posts')
    .post(createPost)
    .validate({
        body: {
            title: t.String()
                .MinLength(5)
                .MaxLength(200)
                .Required(),
            
            content: t.String()
                .MinLength(50)
                .Required(),
            
            tags: t.Array(t.String())
                .MinLength(1)
                .MaxLength(10)
                .Optional(),
            
            published: t.Boolean()
                .Default(false),
            
            metadata: t.Object({
                author: t.String().Required(),
                category: t.Enum(['tech', 'news', 'blog']).Required(),
            }).Optional(),
        }
    });

Query Parameters

app.route('/api/users')
    .get(searchUsers)
    .validate({
        query: {
            search: t.String().MinLength(2).Optional(),
            page: t.Number().Min(1).Default(1),
            limit: t.Number().Min(1).Max(100).Default(20),
            sortBy: t.Enum(['name', 'createdAt', 'email']).Default('createdAt'),
            order: t.Enum(['asc', 'desc']).Default('desc'),
        }
    });

URL Parameters

app.route('/api/users/:id')
    .get(getUser)
    .validate({
        params: {
            id: t.Number().Positive().Integer().Required(),
        }
    });

app.route('/api/posts/:slug')
    .get(getPost)
    .validate({
        params: {
            slug: t.String()
                .Pattern(/^[a-z0-9-]+$/)
                .MinLength(3)
                .MaxLength(100)
                .Required(),
        }
    });

Headers

app.route('/api/admin/*')
    .use(validateAdmin)
    .validate({
        headers: {
            'authorization': t.String()
                .Pattern(/^Bearer .+/)
                .Required(),
            
            'x-request-id': t.String()
                .UUID()
                .Optional(),
            
            'content-type': t.String()
                .Enum(['application/json'])
                .Required(),
        }
    });

Validation Errors

When validation fails, Bunty returns a structured error response:

{
    "error": "Validation failed",
    "statusCode": 400,
    "details": [
        {
            "field": "body.email",
            "message": "Must be a valid email address",
            "value": "invalid-email"
        },
        {
            "field": "body.password",
            "message": "Must be at least 8 characters long",
            "value": "123"
        },
        {
            "field": "query.page",
            "message": "Must be a number greater than or equal to 1",
            "value": "0"
        }
    ]
}

Custom Error Messages

Provide custom error messages for better UX:

app.route('/api/users')
    .post(createUser)
    .validate({
        body: {
            email: t.String()
                .Email()
                .Required()
                .Message('Please provide a valid email address'),
            
            password: t.String()
                .MinLength(8)
                .Pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
                .Required()
                .Message('Password must be at least 8 characters with uppercase, lowercase, and numbers'),
            
            age: t.Number()
                .Min(18)
                .Message('You must be at least 18 years old to register'),
        }
    });

Async Validation

Perform async validation (database checks, external API calls):

import { UserService } from './services/UserService';

app.route('/api/users')
    .post(createUser)
    .validate({
        body: {
            email: t.String()
                .Email()
                .Required()
                .Custom(async (email) => {
                    const userService = app.resolve(UserService);
                    const exists = await userService.existsByEmail(email);
                    if (exists) {
                        throw new Error('Email already registered');
                    }
                    return email;
                }),
            
            username: t.String()
                .MinLength(3)
                .Required()
                .Custom(async (username) => {
                    // Check if username is available
                    const available = await checkUsernameAvailable(username);
                    if (!available) {
                        throw new Error('Username already taken');
                    }
                    return username;
                }),
        }
    });

Sanitization

Automatically sanitize input data:

app.route('/api/posts')
    .post(createPost)
    .validate({
        body: {
            title: t.String()
                .Trim()           // Remove whitespace
                .Required(),
            
            email: t.String()
                .Email()
                .Lowercase()      // Convert to lowercase
                .Required(),
            
            tags: t.Array(t.String().Trim().Lowercase())
                .Unique()         // Remove duplicates
                .Optional(),
            
            content: t.String()
                .StripHTML()      // Remove HTML tags
                .Required(),
        }
    });

Conditional Validation

Validate based on other fields:

app.route('/api/checkout')
    .post(checkout)
    .validate({
        body: {
            paymentMethod: t.Enum(['card', 'paypal', 'bank'])
                .Required(),
            
            // Only required if paymentMethod is 'card'
            cardNumber: t.String()
                .When('paymentMethod', 'card', t.String().Required()),
            
            cardCVV: t.String()
                .When('paymentMethod', 'card', t.String().Length(3).Required()),
            
            // Only required if paymentMethod is 'paypal'
            paypalEmail: t.String()
                .When('paymentMethod', 'paypal', t.String().Email().Required()),
            
            // Only required if paymentMethod is 'bank'
            bankAccount: t.String()
                .When('paymentMethod', 'bank', t.String().Required()),
        }
    });

Partial Validation

Validate only specific fields (useful for PATCH requests):

app.route('/api/users/:id')
    .patch(updateUser)
    .validate({
        body: t.Partial({
            email: t.String().Email(),
            username: t.String().MinLength(3),
            bio: t.String().MaxLength(500),
            age: t.Number().Min(18),
        })
    });

Transform Data

Transform validated data before it reaches your handler:

app.route('/api/users')
    .post(createUser)
    .validate({
        body: {
            email: t.String()
                .Email()
                .Transform((email) => email.toLowerCase()),
            
            tags: t.Array(t.String())
                .Transform((tags) => tags.map(tag => tag.trim().toLowerCase())),
            
            price: t.String()
                .Pattern(/^\$?\d+(\.\d{2})?$/)
                .Transform((price) => parseFloat(price.replace('$', ''))),
            
            createdAt: t.String()
                .ISO8601()
                .Transform((date) => new Date(date)),
        }
    });

Reusable Schemas

Define reusable validation schemas:

import { t } from '@bunty/common';

// Define common schemas
export const emailSchema = t.String()
    .Email()
    .Lowercase()
    .Trim()
    .Required();

export const passwordSchema = t.String()
    .MinLength(8)
    .Pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
    .Required()
    .Message('Password must contain uppercase, lowercase, number, and special character');

export const paginationSchema = {
    page: t.Number().Min(1).Default(1),
    limit: t.Number().Min(1).Max(100).Default(20),
};

export const userCreateSchema = {
    email: emailSchema,
    password: passwordSchema,
    username: t.String().MinLength(3).MaxLength(30).Required(),
    age: t.Number().Min(18).Optional(),
};

// Use in routes
app.route('/api/users')
    .post(createUser)
    .validate({ body: userCreateSchema });

app.route('/api/posts')
    .get(listPosts)
    .validate({ query: paginationSchema });

Manual Validation

Validate data manually in your services:

import { validate } from '@bunty/common';

class UserService {
    async createUser(data: unknown) {
        // Manually validate
        const validatedData = await validate(data, {
            email: t.String().Email().Required(),
            password: t.String().MinLength(8).Required(),
            age: t.Number().Min(18).Optional(),
        });
        
        // validatedData is now typed and validated
        return await this.db.insert(usersTable, validatedData);
    }
}

Validation in Services

Use validation in business logic:

import { t, validate } from '@bunty/common';

@Injectable()
class OrderService {
    async createOrder(data: unknown) {
        // Validate order data
        const order = await validate(data, {
            items: t.Array(t.Object({
                productId: t.Number().Positive().Required(),
                quantity: t.Number().Min(1).Max(100).Required(),
                price: t.Number().Positive().Required(),
            })).MinLength(1).Required(),
            
            shippingAddress: t.Object({
                street: t.String().Required(),
                city: t.String().Required(),
                zipCode: t.String().Pattern(/^\d{5}$/).Required(),
                country: t.String().Length(2).Required(),
            }).Required(),
            
            total: t.Number()
                .Positive()
                .Required()
                .Custom((total, data) => {
                    // Validate total matches items
                    const calculatedTotal = data.items.reduce(
                        (sum, item) => sum + (item.price * item.quantity),
                        0
                    );
                    if (Math.abs(total - calculatedTotal) > 0.01) {
                        throw new Error('Total does not match items');
                    }
                    return total;
                }),
        });
        
        // Process order...
    }
}

Best Practices

βœ… Do

  • Validate all external input (body, query, params, headers)
  • Define reusable schemas for common patterns
  • Use type inference for automatic TypeScript types
  • Provide clear error messages
  • Sanitize user input to prevent XSS/injection
  • Validate in services for business logic
  • Use async validation for database checks

❌ Don’t

  • Trust client-side validation alone
  • Skip validation for β€œinternal” endpoints
  • Use weak validation rules
  • Expose sensitive error details to clients
  • Forget to validate file uploads
  • Mix validation with business logic

πŸ›‘οΈ Security First

Always validate and sanitize user input. Never trust data from the client. Use strong validation rules and clear error messages to prevent attacks and improve UX.

Next Steps

Have questions? Join our Discord community
Found an issue? Edit this page on GitHub