Validation
Master input validation in Igniter.js using Zod, Valibot, Yup, or any StandardSchemaV1-compliant library for type-safe request handling.
Overview
Validation in Igniter.js ensures that incoming data (request body, query parameters) matches your expected schema before reaching your action handler. This prevents bugs, improves security, and provides automatic type inference.
import { igniter } from '@/igniter';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().min(18)
});
const userController = igniter.controller({
path: '/users',
actions: {
create: igniter.mutation({
path: '/',
method: 'POST',
body: CreateUserSchema, // ✅ Auto-validates and types request.body
handler: async ({ request, response }) => {
// ✅ request.body is fully typed and validated!
const { name, email, age } = request.body;
const user = await db.users.create({ data: { name, email, age } });
return response.created({ user });
}
})
}
});Automatic Type Inference
When you provide a validation schema, TypeScript automatically infers the type of request.body or request.query. No manual type annotations needed!
StandardSchemaV1
Igniter.js uses the StandardSchemaV1 interface, which means it works with any validation library that implements this standard:
- ✅ Zod (most popular)
- ✅ Valibot (lightweight alternative)
- ✅ Yup (with adapter)
- ✅ ArkType
- ✅ Any custom schema implementing StandardSchemaV1
What is StandardSchemaV1?
StandardSchemaV1 is a universal interface for validation libraries. It allows Igniter.js to work with multiple validation libraries without being tied to any specific one.
Body Validation
Validate request bodies for POST, PUT, PATCH requests:
Basic Body Validation
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
published: z.boolean().default(false)
});
const postController = igniter.controller({
path: '/posts',
actions: {
create: igniter.mutation({
path: '/',
method: 'POST',
body: CreatePostSchema,
handler: async ({ request, context, response }) => {
// ✅ request.body is typed as:
// {
// title: string;
// content: string;
// published: boolean;
// }
const post = await context.db.posts.create({
data: request.body
});
return response.created({ post });
}
})
}
});Nested Objects
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
profile: z.object({
bio: z.string().optional(),
avatar: z.string().url().optional(),
social: z.object({
twitter: z.string().optional(),
github: z.string().optional()
}).optional()
}),
settings: z.object({
newsletter: z.boolean().default(true),
notifications: z.boolean().default(true)
})
});
// Usage
const userController = igniter.controller({
path: '/users',
actions: {
create: igniter.mutation({
method: 'POST',
body: CreateUserSchema,
handler: async ({ request, response }) => {
// ✅ Fully typed nested access
const { name, email, profile, settings } = request.body;
console.log(profile.social?.twitter);
console.log(settings.newsletter);
}
})
}
});Arrays
const CreatePostSchema = z.object({
title: z.string(),
content: z.string(),
tags: z.array(z.string()).min(1).max(5), // 1-5 tags
categories: z.array(z.enum(['tech', 'design', 'business'])),
coAuthors: z.array(z.object({
name: z.string(),
email: z.string().email()
})).optional()
});
// Usage
const handler = async ({ request }) => {
// ✅ request.body.tags is string[]
// ✅ request.body.categories is ('tech' | 'design' | 'business')[]
// ✅ request.body.coAuthors is { name: string; email: string }[] | undefined
};Query Validation
Validate URL query parameters:
Basic Query Validation
const SearchSchema = z.object({
q: z.string().min(1),
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0)
});
const productController = igniter.controller({
path: '/products',
actions: {
search: igniter.query({
path: '/search',
query: SearchSchema,
handler: async ({ request, context, response }) => {
// ✅ request.query is typed as:
// {
// q: string;
// limit: number;
// offset: number;
// }
const { q, limit, offset } = request.query;
const products = await context.db.products.findMany({
where: { name: { contains: q } },
take: limit,
skip: offset
});
return response.success({ products });
}
})
}
});
// Usage: GET /products/search?q=laptop&limit=10&offset=0Use z.coerce for Query Params
Query parameters come as strings from URLs. Use z.coerce.number(), z.coerce.boolean(), etc. to automatically convert them!
Advanced Query Validation
const ProductSearchSchema = z.object({
q: z.string().min(1),
category: z.enum(['electronics', 'clothing', 'books']).optional(),
minPrice: z.coerce.number().min(0).optional(),
maxPrice: z.coerce.number().min(0).optional(),
sortBy: z.enum(['price', 'date', 'popularity']).default('date'),
order: z.enum(['asc', 'desc']).default('desc'),
inStock: z.coerce.boolean().optional(),
tags: z.string().transform(val => val.split(',')).optional() // "tag1,tag2" → ["tag1", "tag2"]
});
const handler = async ({ request }) => {
const { q, category, minPrice, maxPrice, sortBy, order, inStock, tags } = request.query;
// ✅ All types are inferred correctly:
// q: string
// category: 'electronics' | 'clothing' | 'books' | undefined
// minPrice: number | undefined
// sortBy: 'price' | 'date' | 'popularity'
// tags: string[] | undefined
};Validation Errors
When validation fails, Igniter.js automatically returns a 400 Bad Request with detailed error information:
Example Validation Error
Request:
POST /users
{
"name": "J",
"email": "invalid-email",
"age": 15
}Response (400 Bad Request):
{
"error": "Validation failed",
"issues": [
{
"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"
}
]
}Common Validation Patterns
Optional Fields
const UpdateUserSchema = z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
bio: z.string().max(500).optional()
});
// Or use .partial() to make all fields optional
const UpdateUserSchema2 = z.object({
name: z.string().min(2),
email: z.string().email(),
bio: z.string().max(500)
}).partial();Default Values
const CreatePostSchema = z.object({
title: z.string(),
content: z.string(),
published: z.boolean().default(false), // ✅ Defaults to false if not provided
views: z.number().default(0),
tags: z.array(z.string()).default([])
});Transformations
const CreateUserSchema = z.object({
email: z.string().email().transform(val => val.toLowerCase()), // ✅ Auto-lowercase
name: z.string().transform(val => val.trim()), // ✅ Auto-trim
age: z.string().transform(val => parseInt(val, 10)) // ✅ Convert to number
});Custom Refinements
const CreateUserSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"]
});Conditional Validation
const CreateOrderSchema = z.object({
items: z.array(z.object({
productId: z.string(),
quantity: z.number().min(1)
})),
paymentMethod: z.enum(['credit_card', 'paypal', 'bank_transfer']),
creditCard: z.object({
number: z.string(),
cvv: z.string(),
expiryDate: z.string()
}).optional()
}).refine(
data => {
// Require credit card details if payment method is credit card
if (data.paymentMethod === 'credit_card') {
return !!data.creditCard;
}
return true;
},
{
message: "Credit card details are required for credit card payments",
path: ["creditCard"]
}
);Combining Body and Query Validation
You can validate both at the same time:
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email()
});
const InviteQuerySchema = z.object({
inviteCode: z.string().length(8)
});
const userController = igniter.controller({
path: '/users',
actions: {
createWithInvite: igniter.mutation({
path: '/',
method: 'POST',
body: CreateUserSchema, // ✅ Validates body
query: InviteQuerySchema, // ✅ Validates query
handler: async ({ request, response }) => {
// ✅ Both request.body and request.query are typed and validated
const { name, email } = request.body;
const { inviteCode } = request.query;
// Verify invite code
const invite = await db.invites.findUnique({
where: { code: inviteCode }
});
if (!invite) {
return response.badRequest({ message: 'Invalid invite code' });
}
// Create user
const user = await db.users.create({ data: { name, email } });
return response.created({ user });
}
})
}
});
// Usage: POST /users?inviteCode=ABC12345
// Body: { "name": "John", "email": "john@example.com" }Using Valibot
Valibot is a lightweight alternative to Zod with similar API:
import * as v from 'valibot';
const CreateUserSchema = v.object({
name: v.pipe(v.string(), v.minLength(2)),
email: v.pipe(v.string(), v.email()),
age: v.pipe(v.number(), v.integer(), v.minValue(18))
});
const userController = igniter.controller({
path: '/users',
actions: {
create: igniter.mutation({
path: '/',
method: 'POST',
body: CreateUserSchema, // ✅ Works the same as Zod!
handler: async ({ request, response }) => {
// ✅ request.body is fully typed
const { name, email, age } = request.body;
}
})
}
});Schema Reusability
Extract Common Schemas
// schemas/user.schema.ts
export const EmailSchema = z.string().email();
export const PasswordSchema = z.string().min(8).max(100);
export const NameSchema = z.string().min(2).max(50);
export const UserSchema = z.object({
name: NameSchema,
email: EmailSchema,
password: PasswordSchema
});
export const CreateUserSchema = UserSchema;
export const UpdateUserSchema = UserSchema.partial();
export const LoginSchema = UserSchema.pick({ email: true, password: true });Compose Schemas
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
state: z.string(),
zip: z.string()
});
const UserSchema = z.object({
name: z.string(),
email: z.string().email()
});
const UserWithAddressSchema = UserSchema.extend({
address: AddressSchema
});
// Or merge
const UserWithAddressSchema2 = UserSchema.merge(
z.object({ address: AddressSchema })
);Inheritance
const BaseEntitySchema = z.object({
id: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
const UserSchema = BaseEntitySchema.extend({
name: z.string(),
email: z.string().email()
});
const PostSchema = BaseEntitySchema.extend({
title: z.string(),
content: z.string(),
authorId: z.string()
});Best Practices
1. Use Descriptive Error Messages
const CreateUserSchema = z.object({
email: z.string().email('Please provide a valid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must not exceed 100 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
age: z.number()
.int('Age must be a whole number')
.min(18, 'You must be at least 18 years old')
.max(120, 'Age seems unrealistic')
});2. Extract Schemas into Separate Files
// ✅ Good - Organized and reusable
// schemas/user.schema.ts
export const CreateUserSchema = z.object({ /* ... */ });
export const UpdateUserSchema = z.object({ /* ... */ });
// features/users/user.controller.ts
import { CreateUserSchema, UpdateUserSchema } from '@/schemas/user.schema';3. Use z.coerce for URL Parameters
// ✅ Good - Auto-converts strings to numbers
const PaginationSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20)
});
// ❌ Bad - Will fail because URL params are strings
const PaginationSchema = z.object({
page: z.number().min(1).default(1), // ❌ "1" is not a number!
limit: z.number().min(1).max(100).default(20)
});4. Validate Early
// ✅ Good - Validation happens before handler
const action = igniter.mutation({
body: CreateUserSchema,
handler: async ({ request }) => {
// request.body is already validated
}
});
// ❌ Bad - Manual validation in handler
const action = igniter.mutation({
handler: async ({ request }) => {
const result = CreateUserSchema.safeParse(request.body);
if (!result.success) {
// Handle errors manually...
}
}
});Advanced Validation
Async Refinements
const CreateUserSchema = z.object({
email: z.string().email()
}).refine(
async (data) => {
// Check if email already exists
const existing = await db.users.findUnique({
where: { email: data.email }
});
return !existing;
},
{
message: "Email already in use",
path: ["email"]
}
);Custom Validators
const isValidCreditCard = (cardNumber: string) => {
// Luhn algorithm
return /* validation logic */;
};
const PaymentSchema = z.object({
cardNumber: z.string().refine(isValidCreditCard, {
message: "Invalid credit card number"
}),
cvv: z.string().length(3).regex(/^\d{3}$/, "CVV must be 3 digits"),
expiryDate: z.string().regex(/^\d{2}\/\d{2}$/, "Format: MM/YY")
});Next Steps
Procedures
Create reusable middleware with Igniter.js procedures to handle authentication, validation, logging, and cross-cutting concerns with full type safety.
Response Types
Master all response types in Igniter.js including success, error, streaming, cookies, headers, and cache revalidation for building robust APIs.