Validation System
Input validation with Zod
The Validation System ensures all inputs are validated before your handlers execute - powered by Zod schemas.
How It Works
Every query/mutation has args validated by Zod:
const getUser = t.query({
args: z.object({
id: z.number().positive(),
}),
handler: async (ctx, args) => {
// args.id is guaranteed to be a positive number
return success(await ctx.db.find(args.id));
},
});
// Invalid input returns validation error
const result = await getUser({ id: -5 });
// Failure: ValidationErrorZod Schema Validation
Basic Types
const createUser = t.mutation({
args: z.object({
name: z.string(),
age: z.number(),
active: z.boolean(),
metadata: z.record(z.string()),
}),
handler: async (ctx, args) => {
return success(await ctx.db.create(args));
},
});String Validation
args: z.object({
email: z.string().email(),
username: z.string().min(3).max(20),
password: z.string().min(8).regex(/[A-Z]/),
url: z.string().url(),
uuid: z.string().uuid(),
})Number Validation
args: z.object({
age: z.number().int().positive(),
rating: z.number().min(1).max(5),
price: z.number().nonnegative(),
quantity: z.number().int().min(0).max(100),
})Arrays
args: z.object({
tags: z.array(z.string()).min(1).max(5),
scores: z.array(z.number()).length(10),
items: z.array(z.object({
id: z.number(),
name: z.string(),
})),
})Optional and Nullable
args: z.object({
required: z.string(),
optional: z.string().optional(),
nullable: z.string().nullable(),
optionalNullable: z.string().optional().nullable(),
withDefault: z.string().default("hello"),
})Enums
args: z.object({
status: z.enum(["pending", "active", "completed"]),
role: z.enum(["user", "admin", "moderator"]),
})Object Shapes
args: z.object({
user: z.object({
name: z.string(),
email: z.string().email(),
}),
address: z.object({
street: z.string(),
city: z.string(),
zip: z.string(),
}).optional(),
})Custom Validation
Add custom validation with refine():
const registerUser = t.mutation({
args: z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"],
}
),
handler: async (ctx, args) => {
return success(await ctx.db.create(args));
},
});Transformations
Transform input before validation:
const search = t.query({
args: z.object({
query: z.string().transform((val) => val.trim().toLowerCase()),
limit: z.string().transform((val) => parseInt(val, 10)),
}),
handler: async (ctx, args) => {
// args.query is trimmed and lowercase
// args.limit is a number
return success(await ctx.db.search(args));
},
});Complex Schemas
Union Types
args: z.object({
sort: z.union([
z.literal("name"),
z.literal("date"),
z.literal("popularity"),
]),
})Discriminated Unions
const createEvent = t.mutation({
args: z.discriminatedUnion("type", [
z.object({
type: z.literal("click"),
x: z.number(),
y: z.number(),
}),
z.object({
type: z.literal("keypress"),
key: z.string(),
timestamp: z.number(),
}),
]),
handler: async (ctx, args) => {
if (args.type === "click") {
// args.x and args.y available
} else {
// args.key and args.timestamp available
}
return success(event);
},
});Validation Error Responses
When validation fails, errors are descriptive:
const result = await createUser({
name: "A",
email: "not-an-email",
age: -5,
});
// Failure: ValidationError
// {
// message: "Validation failed",
// errors: [
// { path: ["name"], message: "String must contain at least 2 characters" },
// { path: ["email"], message: "Invalid email" },
// { path: ["age"], message: "Number must be greater than 0" }
// ]
// }Best Practices
1. Validate at the Boundary
// ✅ Good - validate at API boundary
const endpoint = t.mutation({
args: z.object({
email: z.string().email(),
}),
handler: async (ctx, args) => {
// Already validated
return success(await ctx.db.create(args));
},
});
// ❌ Bad - validate in handler
const endpoint = t.mutation({
args: z.object({ email: z.string() }),
handler: async (ctx, args) => {
if (!isValidEmail(args.email)) {
return failure(new Error("Invalid email"));
}
},
});2. Use Strict Validation
// ✅ Good - strict validation
args: z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150),
})
// ❌ Bad - too permissive
args: z.object({
email: z.string(),
age: z.number(),
})3. Provide Clear Error Messages
// ✅ Good - custom messages
z.string().min(8, "Password must be at least 8 characters")
// ❌ Bad - generic error messages
z.string().min(8)See Also
- Authorization & Checks - Authorization checks
- Queries & Mutations - Endpoint definition