Type Coercion
Query parameters and form data arrive as strings. Zod’s coercion utilities handle automatic type conversion.
Why Coercion?
Section titled “Why Coercion?”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" → 9007199254740991nQuery Helpers
Section titled “Query Helpers”Velox TS provides shorthand helpers for common query parameter patterns:
import { queryNumber, queryInt, queryBoolean, queryArray, queryEnum, pagination,} from '@veloxts/validation';queryNumber() and queryInt()
Section titled “queryNumber() and queryInt()”.input(z.object({ page: queryInt(1), // Default: 1, coerces to integer limit: queryInt(20), // Default: 20, coerces to integer minPrice: queryNumber(), // Required, coerces to number}))queryBoolean()
Section titled “queryBoolean()”Accepts: "true", "1", "yes", "on" → true
Accepts: "false", "0", "no", "off" → false
.input(z.object({ active: queryBoolean(true), // Default: true deleted: queryBoolean(false), // Default: false verified: queryBoolean(), // Optional, no default}))queryArray()
Section titled “queryArray()”Parses comma-separated strings into arrays:
.input(z.object({ tags: queryArray(), // "a,b,c" → ["a", "b", "c"] ids: queryArray({ min: 1 }), // At least 1 item required categories: queryArray({ max: 5 }), // Max 5 items values: queryArray({ separator: '|' }), // Custom separator}))queryEnum()
Section titled “queryEnum()”Type-safe enum validation:
.input(z.object({ sort: queryEnum(['asc', 'desc'] as const, 'asc'), status: queryEnum(['active', 'pending', 'archived'] as const),}))pagination()
Section titled “pagination()”Pre-built pagination schema:
// Default: { page: 1, limit: 20, maxLimit: 100 }.input(pagination())
// Custom limits.input(pagination({ defaultLimit: 10, maxLimit: 50 }))
// Extend with filters.input(pagination().extend({ search: z.string().optional(), status: queryEnum(['active', 'archived'] as const),}))Common Patterns
Section titled “Common Patterns”Pagination (Manual)
Section titled “Pagination (Manual)”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(async ({ input, ctx }) => { return await 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);}),Related Content
Section titled “Related Content”- Schemas - Schema definitions
- REST Conventions - Query params
- Pagination - Pagination patterns