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
- Learn about Schema Types in detail
- Set up Configuration validation
- Build secure HTTP APIs