Functions

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: ValidationError

Zod 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

On this page