Validation
CruzJS uses Zod for runtime input validation. Zod schemas validate tRPC procedure inputs and provide automatic TypeScript type inference.
Basic schema
Section titled “Basic schema”Define validation schemas in a dedicated file per feature:
import { z } from 'zod';
export const createProjectSchema = z.object({ name: z.string().min(1).max(100).trim(), description: z.string().max(500).optional(), priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),});
export const updateProjectSchema = z.object({ name: z.string().min(1).max(100).trim().optional(), description: z.string().max(500).optional().nullable(), priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),});Using schemas in tRPC procedures
Section titled “Using schemas in tRPC procedures”Pass the schema to .input() on any procedure:
import { createProjectSchema, updateProjectSchema } from './project.validation';
export const projectRouter = router({ create: orgProcedure .input(createProjectSchema) .mutation(async ({ ctx, input }) => { // input is typed as { name: string; description?: string; priority: 'LOW' | 'MEDIUM' | 'HIGH' } }),
update: orgProcedure .input(z.object({ id: z.string(), data: updateProjectSchema, })) .mutation(async ({ ctx, input }) => { // input.id is string, input.data matches the update schema }),});If validation fails, tRPC automatically returns a BAD_REQUEST error with details about which fields failed and why.
Inferring TypeScript types
Section titled “Inferring TypeScript types”Use z.infer to derive TypeScript types from your schemas. This keeps your types and validation in sync:
export const createProjectSchema = z.object({ name: z.string().min(1).max(100).trim(), description: z.string().max(500).optional(), priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),});
// Inferred type: { name: string; description?: string; priority: 'LOW' | 'MEDIUM' | 'HIGH' }export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export const updateProjectSchema = z.object({ name: z.string().min(1).max(100).trim().optional(), description: z.string().max(500).optional().nullable(), priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),});
// Inferred type: { name?: string; description?: string | null; priority?: 'LOW' | 'MEDIUM' | 'HIGH' }export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;Use these types in your service methods:
async create(orgId: string, userId: string, input: CreateProjectInput): Promise<ProjectResponse> { // input is fully typed}Common patterns
Section titled “Common patterns”Strings
Section titled “Strings”z.string() // Any stringz.string().min(1) // Required (non-empty)z.string().min(1).max(100) // Length boundsz.string().trim() // Auto-trim whitespacez.string().min(1).max(100).trim() // Combine all threez.string().toLowerCase() // Normalize to lowercasez.string().url() // Must be a valid URLz.string().uuid() // Must be a UUIDz.string().regex(/^[a-z0-9-]+$/) // Custom pattern (e.g., slug)z.string().email() // Basic email validationz.string().email().toLowerCase().trim() // Normalized emailNumbers
Section titled “Numbers”z.number() // Any numberz.number().int() // Integer onlyz.number().min(0) // Non-negativez.number().min(1).max(100) // Rangez.number().positive() // Greater than 0z.coerce.number() // Coerce string to number (useful for query params)Booleans
Section titled “Booleans”z.boolean() // true or falsez.boolean().default(false) // Defaults to false if omittedz.coerce.boolean() // Coerce from string "true"/"false"z.enum(['LOW', 'MEDIUM', 'HIGH']) // String enumz.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM') // With defaultz.nativeEnum(ProjectStatus) // From TypeScript enumz.date() // Date objectz.string().datetime() // ISO datetime stringz.coerce.date() // Coerce string to DateOptional and nullable fields
Section titled “Optional and nullable fields”z.string().optional() // string | undefinedz.string().nullable() // string | nullz.string().optional().nullable() // string | null | undefined (allows clearing a value)Use .optional() for fields the client can omit entirely, and .nullable() for fields the client can explicitly set to null (e.g., clearing a description).
Arrays
Section titled “Arrays”z.array(z.string()) // string[]z.array(z.string().max(50)).max(10) // Up to 10 strings, each max 50 charsz.array(z.string()).min(1) // At least one itemz.array(z.string()).nonempty() // Same as .min(1) with better typeNested objects
Section titled “Nested objects”const addressSchema = z.object({ street: z.string().min(1), city: z.string().min(1), state: z.string().length(2), zip: z.string().regex(/^\d{5}$/),});
const createOrgSchema = z.object({ name: z.string().min(1).max(100), address: addressSchema.optional(),});Records (key-value maps)
Section titled “Records (key-value maps)”z.record(z.string(), z.unknown()) // { [key: string]: unknown }z.record(z.string(), z.string()) // { [key: string]: string }Custom error messages
Section titled “Custom error messages”Override default error messages for better user-facing feedback:
const createProjectSchema = z.object({ name: z.string({ required_error: 'Project name is required', }) .min(1, 'Project name cannot be empty') .max(100, 'Project name must be 100 characters or less') .trim(),
email: z.string() .email('Please enter a valid email address'),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH'], { errorMap: () => ({ message: 'Priority must be LOW, MEDIUM, or HIGH' }), }),});Reusing and composing schemas
Section titled “Reusing and composing schemas”Extending schemas
Section titled “Extending schemas”const baseProjectSchema = z.object({ name: z.string().min(1).max(100).trim(), description: z.string().max(500).optional(),});
// Create = all fields required (name already is)const createProjectSchema = baseProjectSchema.extend({ priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'), tags: z.array(z.string().max(50)).max(10).optional(),});
// Update = all fields optionalconst updateProjectSchema = baseProjectSchema.partial().extend({ priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),});Partial schemas
Section titled “Partial schemas”.partial() makes every field optional, useful for update operations:
const createSchema = z.object({ name: z.string().min(1), description: z.string(), priority: z.enum(['LOW', 'MEDIUM', 'HIGH']),});
// All fields become optionalconst updateSchema = createSchema.partial();// Type: { name?: string; description?: string; priority?: 'LOW' | 'MEDIUM' | 'HIGH' }Pick and omit
Section titled “Pick and omit”const fullSchema = z.object({ name: z.string(), email: z.string().email(), password: z.string().min(8), bio: z.string(),});
// Only keep specific fieldsconst profileSchema = fullSchema.pick({ name: true, bio: true });
// Remove specific fieldsconst publicSchema = fullSchema.omit({ password: true });Merging schemas
Section titled “Merging schemas”const timestampsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(),});
const projectResponseSchema = createProjectSchema.merge(timestampsSchema).extend({ id: z.string(),});Pagination and filtering schemas
Section titled “Pagination and filtering schemas”Common reusable schemas for list endpoints:
export const paginationSchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20),});
export const sortSchema = z.object({ sortBy: z.string().default('createdAt'), sortOrder: z.enum(['asc', 'desc']).default('desc'),});
// Use in a procedurelist: orgProcedure .input(paginationSchema.merge(sortSchema).extend({ status: z.enum(['ACTIVE', 'ARCHIVED']).optional(), })) .query(async ({ ctx, input }) => { // input.page, input.limit, input.sortBy, input.sortOrder, input.status }),Transforms and refinements
Section titled “Transforms and refinements”Transform input values
Section titled “Transform input values”const createSlugSchema = z.object({ name: z.string().min(1).transform((val) => val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''), ),});// Input: { name: "My Project!" }// Result: { name: "my-project" }Custom validation with refine
Section titled “Custom validation with refine”const dateRangeSchema = z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(),}).refine( (data) => data.endDate > data.startDate, { message: 'End date must be after start date', path: ['endDate'] },);