Convex Fundamentals
Zod Validation
Runtime validation with Zod schemas for type-safe arguments
Zod Validation
This template uses convex-helpers/server/zod4 to add Zod validation to Convex functions. This provides runtime validation and excellent TypeScript inference.
Why Zod with Convex?
- Runtime Validation - Validate arguments before they reach your handler
- Better Errors - Detailed validation error messages
- Type Inference - Zod schemas infer TypeScript types automatically
- Reusable Schemas - Share validation logic between functions
Setting Up zQuery and zMutation
The template creates custom function factories in functions.ts:
import { zCustomQuery, zCustomMutation } from "convex-helpers/server/zod4";
import { query, mutation } from "./_generated/server";
// Base Convex functions
const baseQuery = query;
const baseMutation = mutation;
// Enhanced with auth context
export const zQuery = zCustomQuery(baseQuery, {
input: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
const parsed = parseIdentity(identity);
return {
ctx: {
...ctx,
table: entsTableFactory(ctx, entDefinitions),
identity: parsed,
},
};
},
});
export const zMutation = zCustomMutation(baseMutation, {
input: async (ctx) => {
// Same auth injection as zQuery
},
});Defining Schemas
Create reusable Zod schemas for your function arguments:
// todos/types.ts
import { z } from "zod";
export const TodoStatus = z.enum(["todo", "in_progress", "done"]);
export const TodoPriority = z.enum(["low", "medium", "high"]);
export const CreateTodoArgs = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().max(1000).optional(),
priority: TodoPriority.default("medium"),
dueDate: z.number().optional(),
teamId: z.string().optional(),
});
export const UpdateTodoArgs = z.object({
id: z.string(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(1000).optional(),
status: TodoStatus.optional(),
priority: TodoPriority.optional(),
dueDate: z.number().nullable().optional(),
});
export const TodoFilters = z.object({
status: TodoStatus.optional(),
priority: TodoPriority.optional(),
teamId: z.string().optional(),
userId: z.string().optional(),
search: z.string().optional(),
});Using Schemas in Functions
// todos/functions.ts
import { zQuery, zMutation } from "../functions";
import { CreateTodoArgs, UpdateTodoArgs, TodoFilters } from "./types";
import { zPaginationOpts } from "convex-helpers/server/pagination";
import { z } from "zod";
export const list = zQuery({
args: z.object({
filters: TodoFilters,
paginationOpts: zPaginationOpts,
}),
handler: async (ctx, args) => {
const { filters, paginationOpts } = args;
let query = ctx.table("todos", "organizationId", (q) =>
q.eq("organizationId", ctx.identity.activeOrganizationId)
);
// Apply filters
if (filters.status) {
query = query.filter((q) => q.eq(q.field("status"), filters.status));
}
if (filters.priority) {
query = query.filter((q) => q.eq(q.field("priority"), filters.priority));
}
return await query.paginate(paginationOpts);
},
});
export const create = zMutation({
args: CreateTodoArgs,
handler: async (ctx, args) => {
return await ctx.table("todos").insert({
...args,
status: "todo",
organizationId: ctx.identity.activeOrganizationId,
userId: ctx.identity.userId,
updatedAt: Date.now(),
});
},
});
export const update = zMutation({
args: UpdateTodoArgs,
handler: async (ctx, args) => {
const { id, ...updates } = args;
const todo = await ctx.table("todos").get(id);
if (!todo || todo.organizationId !== ctx.identity.activeOrganizationId) {
throw new Error("Todo not found");
}
return await ctx.table("todos").patch(id, {
...updates,
updatedAt: Date.now(),
});
},
});Pagination with Zod
Use zPaginationOpts for cursor-based pagination:
import { zPaginationOpts } from "convex-helpers/server/pagination";
export const ListArgs = z.object({
filters: TodoFilters,
paginationOpts: zPaginationOpts,
});
// In handler
const results = await query.paginate(args.paginationOpts);
return results;Common Zod Patterns
Optional with Default
z.object({
priority: z.enum(["low", "medium", "high"]).default("medium"),
limit: z.number().int().min(1).max(100).default(20),
});Nullable vs Optional
z.object({
// Can be undefined (omitted)
teamId: z.string().optional(),
// Can be null (explicitly set to null)
dueDate: z.number().nullable(),
// Can be undefined, null, or a value
description: z.string().nullable().optional(),
});Transformations
z.object({
// Trim whitespace
title: z.string().trim().min(1),
// Transform to lowercase
email: z.string().email().toLowerCase(),
// Coerce string to number
limit: z.coerce.number().int().positive(),
});Refinements
z.object({
startDate: z.number(),
endDate: z.number(),
}).refine(
(data) => data.endDate > data.startDate,
{ message: "End date must be after start date" }
);Error Handling
Zod validation errors are thrown before your handler runs:
// Frontend handling
try {
await createTodo({ title: "" }); // Empty title
} catch (error) {
// Error: "Title is required"
}Type Inference
Zod schemas automatically infer TypeScript types:
import { z } from "zod";
const CreateTodoArgs = z.object({
title: z.string(),
priority: z.enum(["low", "medium", "high"]),
});
// Infer the type
type CreateTodoInput = z.infer<typeof CreateTodoArgs>;
// { title: string; priority: "low" | "medium" | "high" }Next, learn about Actions.