TinyKit Pro Docs

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:

  1. Convex Validators (validators.ts) - Backend-only validation using Convex's v. API
  2. 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 + triggers
  • zInternalMutation - Backend-only mutation with Zod validation + triggers
  • zQuery - Frontend-callable query with Zod validation
  • zInternalQuery - Backend-only query with Zod validation
  • zid(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

TypeConventionExample
Mutation args[action][Entity]SchemaupdateProfileSchema, createProductSchema
Form data[entity]FormSchemaprofileFormSchema, orgCreationFormSchema
Shared fields[field]SchemaemailSchema, usernameSchema
Type exports[Schema]InputUpdateProfileInput, 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

SchemaTypeDescription
emailSchemastringRequired email with RFC validation
optionalEmailSchemastring | undefinedOptional email (empty → undefined)
safeString(opts?)stringTrimmed string with min/max constraints
slugSchemastringURL-safe slug validation
urlSchemastringURL format validation
optionalUrlSchemastring | undefinedOptional URL
hexColorSchemastringHex color (#RRGGBB)
cssColorSchemastringAny CSS color string
positiveIntSchemanumberPositive integers (≥1)
nonNegativeIntSchemanumberNon-negative integers (≥0)
ratingSchemanumber1-5 star rating
timestampSchemanumberUnix timestamp in milliseconds
booleanDefaultFalsebooleanDefaults to false
booleanDefaultTruebooleanDefaults 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 explicitly

5. 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

  1. Schema Reuse: Define schemas once, import everywhere
  2. Early Validation: Frontend validation prevents unnecessary backend calls
  3. Type Safety: TypeScript ensures args match schema at compile-time
  4. Minimal Overhead: Zod validation is fast and happens before database operations

Migration Checklist

When adding Zod validation to a new module:

  • Create zodSchemas.ts file in the module
  • Define schemas for all mutations and forms
  • Export TypeScript types using z.infer<typeof schema>
  • Update mutations to use zMutation instead of mutation
  • Update frontend forms to use zodResolver
  • Add common schemas to convex/lib/zod/common.ts if reusable
  • Write unit tests for complex schemas
  • Update documentation

References

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro