Validation System
TinyKit Pro uses Zod for runtime validation across both frontend forms and backend mutations, providing type-safe validation with a single source of truth.
TinyKit Pro uses Zod for runtime validation across both frontend forms and backend mutations, providing type-safe validation with a single source of truth.
Architecture Overview
Dual Validation System
TinyKit Pro maintains two complementary validation systems:
- Convex Validators (
validators.ts) - Backend-only validation using Convex'sv.API - Zod Schemas (
zodSchemas.ts) - Shared validation for frontend forms and backend mutations
convex/
├── [module]/
│ ├── validators.ts # Convex validators (v.id, v.string, etc.)
│ ├── zodSchemas.ts # Zod schemas (z.string, zid, etc.)
│ ├── private/
│ │ └── mutations.ts # Uses Zod schemas via zMutation
│ └── schema.ts # Database schema (uses v. validators)Why Two Systems?
- Convex validators (
v.): Used in database schemas and internal functions - Zod schemas (
z.): Used in frontend forms and backend mutations for consistency
Key Rule: Use zMutation/zQuery for all user-facing functions to ensure frontend and backend validation match exactly.
Zod Integration
Setup
The Zod integration layer is in convex/lib/zod/index.ts:
import {
zMutation,
zInternalMutation,
zQuery,
zInternalQuery,
zid,
} from "../../lib/zod";
import { z } from "zod";
// Zod-validated mutation
export const createItem = zMutation({
args: z.object({
name: z.string().min(1).max(100),
userId: zid("users"), // Type-safe document ID
}),
handler: async (ctx, args) => {
// args is fully validated and typed
return await ctx.db.insert("items", args);
},
});Available Functions
zMutation- Frontend-callable mutation with Zod validation + triggerszInternalMutation- Backend-only mutation with Zod validation + triggerszQuery- Frontend-callable query with Zod validationzInternalQuery- Backend-only query with Zod validationzid(tableName)- Type-safe document ID validator
Important: All zMutation and zInternalMutation automatically include trigger support for timestamp updates.
File Organization
Module Structure
Each module follows this pattern:
// convex/users/zodSchemas.ts - Zod schemas for frontend and backend
export const updateProfileSchema = z.object({
firstName: z.string().min(2).max(50),
lastName: z.string().min(2).max(50),
username: z.string().min(3).max(20),
});
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
// convex/users/private/mutations.ts - Backend mutations
import { zMutation } from "../../lib/zod";
import { updateProfileSchema } from "../zodSchemas";
export const updateProfile = zMutation({
args: updateProfileSchema,
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
await ctx.db.patch(userId, args);
},
});
// src/features/users/profile-form.tsx - Frontend form
import {
updateProfileSchema,
type UpdateProfileInput,
} from "@/convex/users/zodSchemas";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm<UpdateProfileInput>({
resolver: zodResolver(updateProfileSchema),
});Schema Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Mutation args | [action][Entity]Schema | updateProfileSchema, createProductSchema |
| Form data | [entity]FormSchema | profileFormSchema, orgCreationFormSchema |
| Shared fields | [field]Schema | emailSchema, usernameSchema |
| Type exports | [Schema]Input | UpdateProfileInput, CreateProductInput |
Common Validation Library
TinyKit Pro provides reusable validation schemas in convex/lib/zod/common.ts:
Email Validation
import { emailSchema, optionalEmailSchema } from "../lib/zod/common";
// Required email
const schema = z.object({
email: emailSchema, // Validates, lowercases, trims
});
// Optional email
const schema = z.object({
email: optionalEmailSchema, // Empty string → undefined
});String Validation
import { safeString, slugSchema, urlSchema } from "../lib/zod/common";
const schema = z.object({
name: safeString({ min: 2, max: 100 }), // Trims and validates length
slug: slugSchema, // URL-safe slug (lowercase alphanumeric + hyphens)
website: urlSchema, // Valid URL
bio: safeString({ max: 500 }).optional(), // Optional with trimming
});Numeric Validation
import { positiveIntSchema, ratingSchema } from "../lib/zod/common";
const schema = z.object({
quantity: positiveIntSchema, // Positive integers only
rating: ratingSchema, // 1-5 star rating
});Available Common Schemas
| Schema | Type | Description |
|---|---|---|
emailSchema | string | Required email with RFC validation |
optionalEmailSchema | string | undefined | Optional email (empty → undefined) |
safeString(opts?) | string | Trimmed string with min/max constraints |
slugSchema | string | URL-safe slug validation |
urlSchema | string | URL format validation |
optionalUrlSchema | string | undefined | Optional URL |
hexColorSchema | string | Hex color (#RRGGBB) |
cssColorSchema | string | Any CSS color string |
positiveIntSchema | number | Positive integers (≥1) |
nonNegativeIntSchema | number | Non-negative integers (≥0) |
ratingSchema | number | 1-5 star rating |
timestampSchema | number | Unix timestamp in milliseconds |
booleanDefaultFalse | boolean | Defaults to false |
booleanDefaultTrue | boolean | Defaults to true |
Document ID Validation
Use zid() for type-safe document ID validation:
import { zid } from "../../lib/zod";
export const getUserSchema = z.object({
userId: zid("users"), // Type: Id<"users">
orgId: zid("organizations"), // Type: Id<"organizations">
});
// In mutation
export const getUser = zMutation({
args: getUserSchema,
handler: async (ctx, { userId, orgId }) => {
// userId and orgId are typed as Id<"users"> and Id<"organizations">
const user = await ctx.db.get(userId);
const org = await ctx.db.get(orgId);
},
});Form Validation Patterns
React Hook Form Integration
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "convex/react";
import { toast } from "sonner";
// 1. Import Zod schema and type
import { updateProfileSchema, type UpdateProfileInput } from "@/convex/users/zodSchemas";
function ProfileForm() {
// 2. Setup form with Zod resolver
const form = useForm<UpdateProfileInput>({
resolver: zodResolver(updateProfileSchema),
defaultValues: {
firstName: "",
lastName: "",
username: "",
},
});
// 3. Use mutation
const updateProfile = useMutation(api.users.private.mutations.updateProfile);
// 4. Handle submit
const onSubmit = async (data: UpdateProfileInput) => {
try {
await updateProfile(data); // Already validated by form
toast.success("Profile updated!");
} catch (error) {
toast.error("Failed to update profile");
}
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Input {...form.register("firstName")} />
<Input {...form.register("lastName")} />
<Input {...form.register("username")} />
<Button type="submit">Save</Button>
</form>
);
}Form-Specific vs Mutation Schemas
Sometimes forms need different validation than backend mutations:
// convex/users/zodSchemas.ts
// Frontend form schema - simpler validation
export const profileFormSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
username: z.string().min(3),
});
// Backend mutation schema - additional validation + document IDs
export const updateProfileMutationSchema = z.object({
firstName: firstNameSchema, // Detailed validation with regex
lastName: lastNameSchema,
username: usernameSchema,
userId: zid("users"), // Backend-only field
});Backend Mutation Patterns
Basic Mutation
import { zMutation } from "../../lib/zod";
import { createItemSchema } from "../zodSchemas";
export const createItem = zMutation({
args: createItemSchema,
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// args is fully validated
return await ctx.db.insert("items", {
...args,
userId,
});
},
});Mutation with Optional Fields
export const updateItemSchema = z.object({
id: zid("items"),
name: z.string().min(1).optional(),
description: z.string().max(500).optional(),
tags: z.array(z.string()).optional(),
});
export const updateItem = zMutation({
args: updateItemSchema,
handler: async (ctx, { id, ...updates }) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
const item = await ctx.db.get(id);
if (!item) throw new ConvexError("Item not found");
if (item.userId !== userId) throw new ConvexError("Not authorized");
// Only patch fields that were provided
await ctx.db.patch(id, updates);
},
});Mutation with Complex Validation
export const createProductSchema = z
.object({
name: z.string().min(1),
monthlyPrice: z.number().min(0).optional(),
yearlyPrice: z.number().min(0).optional(),
oneTimePrice: z.number().min(0).optional(),
trialDays: z.number().min(0).max(90).optional().default(0),
})
.refine(
(data) =>
data.monthlyPrice !== undefined ||
data.yearlyPrice !== undefined ||
data.oneTimePrice !== undefined,
{
message: "At least one price must be provided",
},
);
export const createProduct = zMutation({
args: createProductSchema,
handler: async (ctx, args) => {
// Schema ensures at least one price is set
const { userId } = await requireAccess(ctx, { userRole: ["admin"] });
return await ctx.db.insert("products", {
...args,
createdBy: userId,
});
},
});Error Handling
Zod Validation Errors
Zod validation happens automatically before the handler runs:
// This mutation will validate args before handler executes
export const createItem = zMutation({
args: z.object({
name: z.string().min(1, "Name is required"),
quantity: z.number().positive("Quantity must be positive"),
}),
handler: async (ctx, args) => {
// If validation fails, handler never runs
// User gets clear error message from Zod
},
});Frontend Error Display
function CreateItemForm() {
const form = useForm<CreateItemInput>({
resolver: zodResolver(createItemSchema),
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Input {...form.register("name")} />
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
<Input type="number" {...form.register("quantity", { valueAsNumber: true })} />
{form.formState.errors.quantity && (
<p className="text-sm text-destructive">
{form.formState.errors.quantity.message}
</p>
)}
</form>
);
}Migration from Convex Validators
If you have existing mutations using Convex validators, migrate them to Zod:
Before (Convex validators)
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const updateUser = mutation({
args: {
userId: v.string(),
name: v.string(),
email: v.string(),
},
handler: async (ctx, args) => {
// Manual validation
if (!args.name || args.name.length < 2) {
throw new ConvexError("Name must be at least 2 characters");
}
await ctx.db.patch(args.userId, {
name: args.name,
email: args.email,
});
},
});After (Zod schemas)
import { zMutation, zid } from "../../lib/zod";
import { z } from "zod";
export const updateUserSchema = z.object({
userId: zid("users"),
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email"),
});
export const updateUser = zMutation({
args: updateUserSchema,
handler: async (ctx, args) => {
// Validation already done
await ctx.db.patch(args.userId, {
name: args.name,
email: args.email,
});
},
});Best Practices
1. Share Schemas Between Frontend and Backend
// ✅ GOOD: Single source of truth
// convex/users/zodSchemas.ts
export const updateProfileSchema = z.object({
name: z.string().min(2).max(50),
email: emailSchema,
});
// Used in both frontend and backend
// Frontend: useForm({ resolver: zodResolver(updateProfileSchema) })
// Backend: zMutation({ args: updateProfileSchema, ... })2. Use Common Schemas
// ✅ GOOD: Reuse common validation logic
import { emailSchema, safeString } from "../lib/zod/common";
export const userSchema = z.object({
email: emailSchema, // Consistent email validation
name: safeString({ min: 2, max: 50 }), // Trimmed with bounds
});
// ❌ BAD: Duplicate validation logic
export const userSchema = z.object({
email: z
.string()
.email()
.transform((val) => val.toLowerCase().trim()),
name: z
.string()
.min(2)
.max(50)
.transform((val) => val.trim()),
});3. Descriptive Error Messages
// ✅ GOOD: Clear, actionable errors
export const usernameSchema = z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be less than 20 characters")
.regex(
/^[a-z0-9_]+$/,
"Username can only contain lowercase letters, numbers, and underscores",
);
// ❌ BAD: Generic errors
export const usernameSchema = z
.string()
.min(3)
.max(20)
.regex(/^[a-z0-9_]+$/);4. Optional Fields with Defaults
// ✅ GOOD: Optional with sensible default
export const createProductSchema = z.object({
name: z.string().min(1),
trialDays: z.number().min(0).max(90).optional().default(0),
featured: z.boolean().optional().default(false),
});
// Now callers don't need to pass trialDays or featured explicitly5. Complex Validation with .refine()
// ✅ GOOD: Cross-field validation
export const dateRangeSchema = z
.object({
startDate: z.number(),
endDate: z.number(),
})
.refine((data) => data.endDate > data.startDate, {
message: "End date must be after start date",
path: ["endDate"], // Show error on endDate field
});Testing
Unit Testing Schemas
import { describe, it, expect } from "vitest";
import { updateProfileSchema } from "./zodSchemas";
describe("updateProfileSchema", () => {
it("validates correct input", () => {
const result = updateProfileSchema.safeParse({
firstName: "John",
lastName: "Doe",
username: "johndoe",
});
expect(result.success).toBe(true);
});
it("rejects invalid username", () => {
const result = updateProfileSchema.safeParse({
firstName: "John",
lastName: "Doe",
username: "ab", // Too short
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors[0].message).toContain("at least 3 characters");
}
});
});Performance Considerations
- Schema Reuse: Define schemas once, import everywhere
- Early Validation: Frontend validation prevents unnecessary backend calls
- Type Safety: TypeScript ensures args match schema at compile-time
- Minimal Overhead: Zod validation is fast and happens before database operations
Migration Checklist
When adding Zod validation to a new module:
- Create
zodSchemas.tsfile in the module - Define schemas for all mutations and forms
- Export TypeScript types using
z.infer<typeof schema> - Update mutations to use
zMutationinstead ofmutation - Update frontend forms to use
zodResolver - Add common schemas to
convex/lib/zod/common.tsif reusable - Write unit tests for complex schemas
- Update documentation
References
Development Patterns
This document contains detailed development patterns, coding standards, and implementation examples for TinyKit Pro.
Logging System
TinyKit Pro includes a comprehensive logging system with environment-based log levels, structured output, and performance timing capabilities. The logging sy...