Type Coercion
Query parameters and form data arrive as strings. Zod’s coercion utilities handle automatic type conversion.
The Problem
Section titled “The Problem”HTTP query parameters are always strings:
GET /api/products?page=2&active=true&minPrice=100Without coercion:
input: z.object({ page: z.number(), // Fails! "2" is a string active: z.boolean(), // Fails! "true" is a string minPrice: z.number(), // Fails! "100" is a string})The Solution: z.coerce
Section titled “The Solution: z.coerce”Use z.coerce for automatic conversion:
input: z.object({ page: z.coerce.number(), // "2" → 2 active: z.coerce.boolean(), // "true" → true minPrice: z.coerce.number(), // "100" → 100})Coercion Types
Section titled “Coercion Types”Numbers
Section titled “Numbers”z.coerce.number() // "42" → 42, "3.14" → 3.14z.coerce.number().int() // "42" → 42, "3.14" → errorz.coerce.number().positive() // Must be > 0z.coerce.number().min(1) // Must be >= 1Booleans
Section titled “Booleans”z.coerce.boolean()// "true" → true// "false" → false// "1" → true// "0" → false// "" → falsez.coerce.date()// "2024-01-15" → Date object// "2024-01-15T10:30:00Z" → Date objectBigInts
Section titled “BigInts”z.coerce.bigint()// "9007199254740991" → 9007199254740991nCommon Patterns
Section titled “Common Patterns”Pagination
Section titled “Pagination”const paginationInput = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), sortBy: z.string().optional(), sortOrder: z.enum(['asc', 'desc']).default('asc'),});
listUsers: procedure() .input(paginationInput) .query(({ input }) => { const { page, limit, sortBy, sortOrder } = input; // page and limit are numbers, not strings }),Filters
Section titled “Filters”const filterInput = z.object({ minPrice: z.coerce.number().optional(), maxPrice: z.coerce.number().optional(), active: z.coerce.boolean().optional(), createdAfter: z.coerce.date().optional(),});
findProducts: procedure() .input(filterInput) .query(({ input, ctx }) => { return ctx.db.product.findMany({ where: { price: { gte: input.minPrice, lte: input.maxPrice, }, active: input.active, createdAt: input.createdAfter ? { gte: input.createdAfter } : undefined, }, }); }),ID Parameters
Section titled “ID Parameters”// For UUID IDs (strings).input(z.object({ id: z.string().uuid() }))
// For numeric IDs.input(z.object({ id: z.coerce.number().int().positive() }))Mixing Coerced and Non-Coerced
Section titled “Mixing Coerced and Non-Coerced”For POST/PUT with JSON body + query params:
updateProduct: procedure() .input(z.object({ // From URL path (string → number) id: z.coerce.number(),
// From JSON body (already correct types) data: z.object({ name: z.string(), price: z.number(), // No coerce needed active: z.boolean(), // No coerce needed }), })) .mutation(handler),Array Query Parameters
Section titled “Array Query Parameters”Handle comma-separated query params like ?ids=1,2,3:
// Using transformconst idsInput = z.object({ ids: z.string().transform(s => s.split(',').map(Number)),});// "1,2,3" → [1, 2, 3]
// Using preprocess for more flexibilityconst tagsInput = z.object({ tags: z.preprocess( (val) => typeof val === 'string' ? val.split(',') : val, z.array(z.string()) ),});// "react,typescript,node" → ["react", "typescript", "node"]
// With coercion for numeric arraysconst productIdsInput = z.object({ productIds: z.preprocess( (val) => typeof val === 'string' ? val.split(',') : val, z.array(z.coerce.number().int().positive()) ),});// "1,2,3" → [1, 2, 3] with validationCommon Mistakes
Section titled “Common Mistakes”Forgetting coercion on query params
Section titled “Forgetting coercion on query params”// ❌ BAD: Validation fails - "1" is not a numberfindProducts: procedure() .input(z.object({ page: z.number(), limit: z.number(), })) .query(handler),
// ✅ GOOD: Strings are coerced to numbersfindProducts: procedure() .input(z.object({ page: z.coerce.number(), limit: z.coerce.number(), })) .query(handler),Using coercion on JSON body
Section titled “Using coercion on JSON body”// ❌ UNNECESSARY: JSON already has correct typescreateProduct: procedure() .input(z.object({ name: z.string(), price: z.coerce.number(), // Don't need coerce for JSON active: z.coerce.boolean(), // Don't need coerce for JSON })) .mutation(handler),
// ✅ CORRECT: JSON body types are already correctcreateProduct: procedure() .input(z.object({ name: z.string(), price: z.number(), active: z.boolean(), })) .mutation(handler),Boolean coercion gotchas
Section titled “Boolean coercion gotchas”// ⚠️ CAUTION: Empty string coerces to falsez.coerce.boolean()// "" → false// "false" → false (string "false" becomes boolean false)// "0" → false
// For stricter boolean parsing, use transform:z.enum(['true', 'false']).transform(val => val === 'true')// Only accepts exactly "true" or "false"Handling Prisma Decimals
Section titled “Handling Prisma Decimals”Prisma’s Decimal type requires special handling:
import { prismaDecimal } from '@veloxts/validation';
const ProductSchema = z.object({ id: z.string(), name: z.string(), price: prismaDecimal(), // Handles Prisma Decimal serialization});Or use transform:
price: z.any().transform((val) => { if (typeof val === 'object' && 'toNumber' in val) { return val.toNumber(); // Prisma Decimal } return Number(val);}),Next Steps
Section titled “Next Steps”- Schemas - Schema definitions
- REST Conventions - Query params
- Pagination - Pagination patterns