Validation
Validation in Vercube ensures that incoming data meets your requirements before it reaches your business logic. Instead of manually checking every field, Vercube automatically validates and transforms request data using industry-standard validation libraries.
What is Validation?
Validation is the process of checking if incoming data is correct, complete, and safe to use. Without validation, your API is vulnerable to bad data, which can cause crashes, security issues, or data corruption.
The Problem Without Validation
@Post('/users')
createUser(@Body() data: any) {
// ❌ What if email is missing?
// ❌ What if age is negative?
// ❌ What if name is 1000 characters long?
// ❌ What if data is not even an object?
// Manual validation is tedious and error-prone
if (!data.email) throw new Error('Email required');
if (typeof data.age !== 'number') throw new Error('Age must be number');
if (data.age < 0) throw new Error('Age must be positive');
// ... and so on for every field
return this.userService.create(data);
}
The Solution With Validation
const CreateUserSchema = z.object({
email: z.string().email(),
age: z.number().min(0),
name: z.string().max(100)
});
@Post('/users')
createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) {
// ✅ Data is guaranteed to be valid
// ✅ Automatic 400 response if validation fails
// ✅ Type-safe and clean
return this.userService.create(data);
}
How Validation Works
Vercube's validation system is built on Standard Schema, which means it works with any validation library that follows this standard. You're not locked into a specific library!
What Happens Behind the Scenes
When you add validationSchema to a decorator:
- Vercube registers a validation middleware that runs before your handler
- The middleware extracts the data (from body, query params, etc.)
- Your schema validates and transforms the data
- If validation fails, Vercube automatically returns a 400 error with details
- If validation succeeds, your handler receives clean, type-safe data
Standard Schema Support
Vercube supports any validation library that implements the Standard Schema specification. This gives you the freedom to choose the library that best fits your needs.
Supported Libraries
All of these work out of the box:
- Zod - TypeScript-first schema validation
- Valibot - Lightweight, modular validation
- ArkType - TypeScript-native runtime validation
- Typebox - JSON Schema based validation
- And any other Standard Schema compatible library!
Validating Request Body
The most common use case is validating JSON request bodies. Choose your preferred validation library - they all work the same way in Vercube:
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(18).max(120),
role: z.enum(['user', 'admin']).default('user')
});
type CreateUserDto = z.infer<typeof CreateUserSchema>;
@Controller('/users')
export class UserController {
@Post('/')
@Status(201)
createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) {
// data is validated and typed!
return this.userService.create(data);
}
}
import * as v from 'valibot';
const CreateUserSchema = v.object({
name: v.pipe(v.string(), v.minLength(2), v.maxLength(50)),
email: v.pipe(v.string(), v.email()),
age: v.pipe(v.number(), v.integer(), v.minValue(18), v.maxValue(120)),
role: v.optional(v.picklist(['user', 'admin']), 'user')
});
type CreateUserDto = v.InferOutput<typeof CreateUserSchema>;
@Controller('/users')
export class UserController {
@Post('/')
@Status(201)
createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) {
// Same behavior, different library!
return this.userService.create(data);
}
}
import { type } from 'arktype';
const CreateUserSchema = type({
name: 'string>2<50',
'email': 'string.email',
age: 'number.integer>=18<=120',
'role?': '"user"|"admin" = "user"'
});
type CreateUserDto = typeof CreateUserSchema.infer;
@Controller('/users')
export class UserController {
@Post('/')
@Status(201)
createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) {
// Works exactly the same!
return this.userService.create(data);
}
}
Validating Query Parameters
Query parameters are always strings in HTTP, but validation libraries can transform them to the correct types:
import { z } from 'zod';
const SearchUsersSchema = z.object({
// z.coerce converts strings to numbers/booleans
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
active: z.coerce.boolean().optional(),
role: z.enum(['user', 'admin']).optional(),
search: z.string().optional()
});
type SearchUsersDto = z.infer<typeof SearchUsersSchema>;
@Controller('/users')
export class UserController {
@Get('/')
searchUsers(@QueryParams({ validationSchema: SearchUsersSchema }) query: SearchUsersDto) {
// query.page is a number (not string!)
// query.limit is a number (not string!)
// query.active is a boolean (not string!)
return this.userService.search(query);
}
}
# Request:
GET /users?page=2&limit=20&active=true&role=admin
# Your handler receives:
{
page: 2, // number
limit: 20, // number
active: true, // boolean
role: "admin" // string (enum validated)
}
.coerce methods (Zod) or equivalent transformations in other libraries to convert string query parameters to numbers, booleans, dates, etc.Validation Errors
When validation fails, Vercube automatically returns a structured error response:
# Invalid request:
POST /users
{
"name": "A", # Too short
"email": "invalid", # Not an email
"age": 15 # Too young
}
# Automatic response (400 Bad Request):
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
{
"path": ["name"],
"message": "String must contain at least 2 character(s)"
},
{
"path": ["email"],
"message": "Invalid email"
},
{
"path": ["age"],
"message": "Number must be greater than or equal to 18"
}
]
}
Type Safety
One of the biggest benefits of using validation schemas is automatic TypeScript type inference:
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number(),
preferences: z.object({
newsletter: z.boolean(),
theme: z.enum(['light', 'dark'])
})
});
// TypeScript automatically knows the type!
type UserDto = z.infer<typeof UserSchema>;
@Post('/')
createUser(@Body({ validationSchema: UserSchema }) data: UserDto) {
// Full autocomplete and type checking:
data.name // ✅ string
data.email // ✅ string
data.age // ✅ number
data.preferences // ✅ { newsletter: boolean, theme: 'light' | 'dark' }
data.unknown // ❌ TypeScript error!
}
Advanced Validation
const CreateOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1)
})).min(1),
shipping: z.object({
address: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/)
}),
payment: z.object({
method: z.enum(['card', 'paypal']),
token: z.string()
})
});
const RegisterUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string()
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"]
}
);
const CreateArticleSchema = z.object({
title: z.string()
.trim() // Remove whitespace
.min(5)
.max(100),
slug: z.string()
.transform(s => s.toLowerCase()) // Convert to lowercase
.transform(s => s.replace(/\s+/g, '-')), // Replace spaces
publishedAt: z.string()
.datetime()
.transform(s => new Date(s)) // Convert to Date object
});
const UpdateUserSchema = z.object({
name: z.string().optional(), // Field is optional
email: z.string().email().optional(),
role: z.enum(['user', 'admin']).default('user'), // Default value
active: z.boolean().default(true)
});
You can also validate path parameters to ensure they're in the correct format:
const UserIdParamSchema = z.object({
id: z.string().uuid()
});
@Get('/:id')
getUser(@Param({ validationSchema: UserIdParamSchema }) params: { id: string }) {
// params.id is guaranteed to be a valid UUID
return this.userService.findById(params.id);
}
Partial Validation
For update endpoints, you often want to make all fields optional:
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number()
});
// All fields become optional
const UpdateUserSchema = CreateUserSchema.partial();
@Put('/:id')
updateUser(
@Param('id') id: string,
@Body({ validationSchema: UpdateUserSchema }) data: Partial<UserDto>
) {
return this.userService.update(id, data);
}
Reusing Schemas
Define schemas in separate files and reuse them across your application:
import { z } from 'zod';
export const BaseUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(18)
});
export const CreateUserSchema = BaseUserSchema.extend({
password: z.string().min(8)
});
export const UpdateUserSchema = BaseUserSchema.partial();
export type CreateUserDto = z.infer<typeof CreateUserSchema>;
export type UpdateUserDto = z.infer<typeof UpdateUserSchema>;
import { CreateUserSchema, UpdateUserSchema } from './schemas/user.schema';
@Controller('/users')
export class UserController {
@Post('/')
createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) {
return this.userService.create(data);
}
@Put('/:id')
updateUser(
@Param('id') id: string,
@Body({ validationSchema: UpdateUserSchema }) data: UpdateUserDto
) {
return this.userService.update(id, data);
}
}
Best Practices
Define schemas close to usage - Keep schemas in the same file or nearby
// ✅ Good - easy to find and maintain
const CreateUserSchema = z.object({ ... });
@Controller('/users')
export class UserController {
@Post('/')
createUser(@Body({ validationSchema: CreateUserSchema }) data: UserDto) { }
}
Use descriptive error messages - Help clients understand what's wrong
const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number');
Validate early, validate often - Don't let bad data reach your business logic
// ❌ Bad - validation happens too late
@Post('/')
createUser(@Body() data: any) {
// Business logic might fail with invalid data
await this.userService.create(data);
}
// ✅ Good - validation happens first
@Post('/')
createUser(@Body({ validationSchema: CreateUserSchema }) data: UserDto) {
// Guaranteed valid data
await this.userService.create(data);
}
Don't over-validate - Balance strictness with usability
// ❌ Too strict - will frustrate users
const NameSchema = z.string()
.min(2)
.max(20)
.regex(/^[A-Za-z]+$/); // No spaces, special characters
// ✅ Reasonable - accepts most valid names
const NameSchema = z.string()
.min(2)
.max(100)
.trim();
Use transformation wisely - Clean data, but don't change meaning
// ✅ Good transformations
z.string().trim() // Remove whitespace
z.string().toLowerCase() // Normalize case
z.string().datetime().transform(Date) // Parse to Date
// ❌ Questionable transformations
z.string().transform(s => s.substring(0, 10)) // Silently truncating
z.number().transform(n => Math.abs(n)) // Changing sign without telling user
Choosing a Validation Library
All Standard Schema libraries work the same way in Vercube, but each has different strengths:
Zod
Best for: General use, great TypeScript support, large ecosystem
- Most popular, lots of examples and plugins
- Excellent error messages
- Great IDE autocomplete
- Slightly larger bundle size
Valibot
Best for: Bundle size sensitive projects, modular validation
- Smallest bundle size (tree-shakeable)
- Modular - only import what you need
- Similar API to Zod
- Growing ecosystem
ArkType
Best for: Runtime performance, type-first approach
- Fastest runtime validation
- Unique syntax using TypeScript-like strings
- Excellent type inference
- Smaller community (newer)