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