Zod Validation Overview
Type Safety
Compile-time validationAutomated runtime validation
Automatic Validation
Schema-based validationAuto-generated error messages
Type Inference
TypeScript type generationAutocomplete support
Extensible
Custom validation rulesTransform support
Installing Zod
Copy
npm install zod
# or
pnpm add zod
Sonamu supports Zod v4. Install the latest version.
Basic Usage
Defining Zod Schemas
Copy
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
Copy
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
Copy
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
Copy
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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
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
Copy
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
Copy
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
Copy
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
Copy
// 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
Copy
// 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
Copy
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()
Copy
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)
Copy
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)
Copy
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
Copy
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
Copy
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:
- parse() throws exception on validation failure
- safeParse() returns result object
- Transform executes after validation
- refine() executes last
- Reuse schemas for performance