Skip to main content
Sonamu can automatically validate API parameters using Zod.

Zod Validation Overview

Type Safety

Compile-time validation Automated runtime validation

Automatic Validation

Schema-based validation Auto-generated error messages

Type Inference

TypeScript type generation Autocomplete support

Extensible

Custom validation rules Transform support

Installing Zod

npm install zod
# or
pnpm add zod
Sonamu supports Zod v4. Install the latest version.

Basic Usage

Defining Zod Schemas

import { z } from "zod";
import { BaseModelClass, api } from "sonamu";

// Define Zod schema
const CreateUserSchema = z.object({
  email: z.string().email("Invalid email format"),
  username: z.string().min(3, "Username must be at least 3 characters").max(20),
  password: z.string().min(8, "Password must be at least 8 characters"),
  age: z.number().int().min(18, "Must be at least 18 years old").optional(),
  role: z.enum(["admin", "manager", "normal"]),
});

// TypeScript type inference
type CreateUserParams = z.infer<typeof CreateUserSchema>;

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    // Validate with schema
    const validated = CreateUserSchema.parse(params);

    const wdb = this.getPuri("w");
    const [user] = await wdb.table("users").insert(validated).returning({ id: "id" });

    return { userId: user.id };
  }
}

Handling Validation Errors

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    try {
      // Validate
      const validated = CreateUserSchema.parse(params);

      const wdb = this.getPuri("w");
      const [user] = await wdb.table("users").insert(validated).returning({ id: "id" });

      return { userId: user.id };
    } catch (error) {
      if (error instanceof z.ZodError) {
        // Zod validation error
        const messages = error.errors.map((e) => e.message).join(", ");
        throw new Error(`Validation failed: ${messages}`);
      }
      throw error;
    }
  }
}

Zod Schema Types

Primitive Types

import { z } from "zod";

// String
const StringSchema = z.string();
const EmailSchema = z.string().email();
const UrlSchema = z.string().url();
const UuidSchema = z.string().uuid();

// Number
const NumberSchema = z.number();
const IntSchema = z.number().int();
const PositiveSchema = z.number().positive();
const MinMaxSchema = z.number().min(0).max(100);

// Boolean
const BooleanSchema = z.boolean();

// Date
const DateSchema = z.date();
const DateStringSchema = z.string().datetime(); // ISO 8601

Objects

const UserSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3).max(20),
  age: z.number().int().min(18).optional(),
  profile: z
    .object({
      bio: z.string().max(500).optional(),
      avatarUrl: z.string().url().optional(),
      socialLinks: z
        .object({
          twitter: z.string().url().optional(),
          github: z.string().url().optional(),
        })
        .optional(),
    })
    .optional(),
});

type User = z.infer<typeof UserSchema>;
// {
//   email: string;
//   username: string;
//   age?: number;
//   profile?: {
//     bio?: string;
//     avatarUrl?: string;
//     socialLinks?: {
//       twitter?: string;
//       github?: string;
//     };
//   };
// }

Arrays

// String array
const StringArraySchema = z.array(z.string());

// Object array
const UserArraySchema = z.array(
  z.object({
    id: z.number(),
    name: z.string(),
  }),
);

// Min/max length
const ArraySchema = z.array(z.string()).min(1).max(10);

// Non-empty array
const NonEmptyArraySchema = z.array(z.string()).nonempty();

Enum and Literal

// Enum
const RoleSchema = z.enum(["admin", "manager", "normal"]);
type Role = z.infer<typeof RoleSchema>; // "admin" | "manager" | "normal"

// Literal (Zod v4 supports multiple values)
const StatusSchema = z.literal(["active", "inactive", "pending"]);

// Union
const RoleOrStatusSchema = z.union([z.literal("admin"), z.literal("user"), z.literal("guest")]);

Optional and Nullable

// Optional (allows undefined)
const OptionalSchema = z.string().optional();
// string | undefined

// Nullable (allows null)
const NullableSchema = z.string().nullable();
// string | null

// Both allowed
const NullishSchema = z.string().nullish();
// string | null | undefined

// Default value
const DefaultSchema = z.string().default("default value");

Practical Examples

User Registration

const RegisterSchema = z
  .object({
    email: z.string().email("Please enter a valid email"),
    username: z
      .string()
      .min(3, "Username must be at least 3 characters")
      .max(20, "Username can be up to 20 characters")
      .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
    password: z
      .string()
      .min(8, "Password must be at least 8 characters")
      .regex(/[A-Z]/, "Password must contain at least 1 uppercase letter")
      .regex(/[a-z]/, "Password must contain at least 1 lowercase letter")
      .regex(/[0-9]/, "Password must contain at least 1 number"),
    confirmPassword: z.string(),
    agreeToTerms: z.boolean().refine((val) => val === true, {
      message: "You must agree to the terms of service",
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"],
  });

type RegisterParams = z.infer<typeof RegisterSchema>;

class AuthModelClass extends BaseModelClass<
  AuthSubsetKey,
  AuthSubsetMapping,
  typeof authSubsetQueries,
  typeof authLoaderQueries
> {
  constructor() {
    super("Auth", authSubsetQueries, authLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async register(params: RegisterParams): Promise<{ userId: number }> {
    const validated = RegisterSchema.parse(params);

    const wdb = this.getPuri("w");

    // Save excluding confirmPassword
    const { confirmPassword, agreeToTerms, ...userData } = validated;

    const [user] = await wdb
      .table("users")
      .insert({
        ...userData,
        role: "normal",
      })
      .returning({ id: "id" });

    return { userId: user.id };
  }
}

Pagination Parameters

const PaginationSchema = z.object({
  page: z.number().int().positive().default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  sortBy: z.enum(["created_at", "updated_at", "name"]).optional(),
  sortOrder: z.enum(["asc", "desc"]).default("desc"),
});

const UserListSchema = PaginationSchema.extend({
  search: z.string().optional(),
  role: z.enum(["admin", "manager", "normal"]).optional(),
  isActive: z.boolean().optional(),
});

type UserListParams = z.infer<typeof UserListSchema>;

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "GET" })
  async list(params: UserListParams): Promise<{
    users: User[];
    total: number;
    page: number;
    pageSize: number;
  }> {
    const validated = UserListSchema.parse(params);

    const rdb = this.getPuri("r");

    let query = rdb.table("users");

    if (validated.search) {
      query = query.where("username", "like", `%${validated.search}%`);
    }

    if (validated.role) {
      query = query.where("role", validated.role);
    }

    if (validated.isActive !== undefined) {
      query = query.where("is_active", validated.isActive);
    }

    const users = await query
      .orderBy(validated.sortBy || "created_at", validated.sortOrder)
      .limit(validated.pageSize)
      .offset((validated.page - 1) * validated.pageSize)
      .select("*");

    const [{ count }] = await rdb.table("users").count({ count: "*" });

    return {
      users,
      total: count,
      page: validated.page,
      pageSize: validated.pageSize,
    };
  }
}

Date Range Validation

const DateRangeSchema = z
  .object({
    startDate: z.string().datetime(),
    endDate: z.string().datetime(),
  })
  .refine((data) => new Date(data.startDate) < new Date(data.endDate), {
    message: "Start date must be before end date",
    path: ["endDate"],
  });

const ReportSchema = z.object({
  dateRange: DateRangeSchema,
  type: z.enum(["sales", "traffic", "conversion"]),
  format: z.enum(["pdf", "excel", "csv"]).default("pdf"),
});

type ReportParams = z.infer<typeof ReportSchema>;

class ReportModelClass extends BaseModelClass<
  ReportSubsetKey,
  ReportSubsetMapping,
  typeof reportSubsetQueries,
  typeof reportLoaderQueries
> {
  constructor() {
    super("Report", reportSubsetQueries, reportLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async generate(params: ReportParams): Promise<{ reportId: number }> {
    const validated = ReportSchema.parse(params);

    // Report generation logic
    // ...
  }
}

Transform (Data Transformation)

String Transformation

const TrimmedStringSchema = z.string().transform((val) => val.trim());

const LowercaseEmailSchema = z
  .string()
  .email()
  .transform((val) => val.toLowerCase());

const UserSchema = z.object({
  email: z
    .string()
    .email()
    .transform((val) => val.toLowerCase()),
  username: z.string().transform((val) => val.trim()),
});

Number Transformation

// Convert string to number
const StringToNumberSchema = z.string().transform((val) => parseInt(val, 10));

// Coerce (forced conversion)
const CoerceNumberSchema = z.coerce.number();
// "123" → 123
// true → 1
// false → 0

Date Transformation

// ISO string to Date object
const DateFromStringSchema = z
  .string()
  .datetime()
  .transform((val) => new Date(val));

// Coerce
const CoerceDateSchema = z.coerce.date();
// "2025-01-07" → Date object
// "2025-01-07T12:00:00Z" → Date object

Complex Transformation

const ProductSchema = z.object({
  name: z.string(),
  price: z.string().transform((val) => parseFloat(val)),
  tags: z.string().transform((val) => val.split(",").map((t) => t.trim())),
  metadata: z.string().transform((val) => {
    try {
      return JSON.parse(val);
    } catch {
      return {};
    }
  }),
});

// Input:
// {
//   name: "Product",
//   price: "29.99",
//   tags: "electronics, gadget, new",
//   metadata: '{"color": "blue", "size": "M"}'
// }

// Output:
// {
//   name: "Product",
//   price: 29.99,
//   tags: ["electronics", "gadget", "new"],
//   metadata: { color: "blue", size: "M" }
// }

Custom Validation

Using refine()

const PasswordSchema = z
  .string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), {
    message: "Uppercase required",
  })
  .refine((val) => /[a-z]/.test(val), {
    message: "Lowercase required",
  })
  .refine((val) => /[0-9]/.test(val), {
    message: "Number required",
  });

// Validate entire object
const UserSchema = z
  .object({
    password: z.string(),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"],
  });

Using superRefine() (Complex Validation)

const ComplexSchema = z
  .object({
    startDate: z.date(),
    endDate: z.date(),
    participants: z.array(z.string()),
  })
  .superRefine((data, ctx) => {
    // Date validation
    if (data.startDate >= data.endDate) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Start date must be before end date",
        path: ["endDate"],
      });
    }

    // Participant count validation
    if (data.participants.length < 2) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "At least 2 participants required",
        path: ["participants"],
      });
    }

    // Duplicate validation
    const uniqueParticipants = new Set(data.participants);
    if (uniqueParticipants.size !== data.participants.length) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Duplicate participants found",
        path: ["participants"],
      });
    }
  });

safeParse (Safe Validation)

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    // safeParse doesn't throw exceptions
    const result = CreateUserSchema.safeParse(params);

    if (!result.success) {
      // Validation failed
      const context = Sonamu.getContext();
      context.reply.status(400);

      const errors = result.error.errors.map((e) => ({
        field: e.path.join("."),
        message: e.message,
      }));

      throw new Error(JSON.stringify({ errors }));
    }

    // Validation succeeded
    const validated = result.data;

    const wdb = this.getPuri("w");
    const [user] = await wdb.table("users").insert(validated).returning({ id: "id" });

    return { userId: user.id };
  }
}

Customizing Error Messages

Global Error Map

import { z } from "zod";

// Customize error messages
z.setErrorMap((issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    if (issue.expected === "string") {
      return { message: "Must be a string" };
    }
    if (issue.expected === "number") {
      return { message: "Must be a number" };
    }
  }

  if (issue.code === z.ZodIssueCode.too_small) {
    if (issue.type === "string") {
      return { message: `Must be at least ${issue.minimum} characters` };
    }
  }

  return { message: ctx.defaultError };
});

Individual Error Messages

const UserSchema = z.object({
  email: z
    .string({
      required_error: "Email is required",
      invalid_type_error: "Email must be a string",
    })
    .email("Invalid email format"),

  age: z
    .number({
      required_error: "Age is required",
      invalid_type_error: "Age must be a number",
    })
    .min(18, "Must be at least 18 years old"),
});

Cautions

Cautions when using Zod validation: 1. parse() throws exception on validation failure 2. safeParse() returns result object 3. Transform executes after validation 4. refine() executes last 5. Reuse schemas for performance

Next Steps

Custom Validation

Implementing custom validation logic

Error Handling

Using SonamuError

Parameters

API parameter definitions

@api Decorator

API basic usage