Validation

Automatic request validation with Standard Schema support

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:

  1. Vercube registers a validation middleware that runs before your handler
  2. The middleware extracts the data (from body, query params, etc.)
  3. Your schema validates and transforms the data
  4. If validation fails, Vercube automatically returns a 400 error with details
  5. 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!
You can even mix different libraries in the same project! Use Zod for complex validations and Valibot for simple ones - Vercube doesn't care, it works with all of them.

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);
  }
}

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)
}
Remember to use .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"
    }
  ]
}
The exact error format may vary slightly between validation libraries, but they all provide clear, actionable error messages that you can return to clients.

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()
  })
});

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:

schemas/user.schema.ts
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>;
UserController.ts
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)
Start with Zod if you're unsure - it has the best documentation and community support. You can always switch later thanks to Standard Schema!
Previous

Overview

Flexible authentication system for Vercube applications

Next