Zod ๊ฒ์ฆ ๊ฐ์
ํ์ ์์ ์ฑ
์ปดํ์ผ ํ์ ๊ฒ์ฆ๋ฐํ์ ๊ฒ์ฆ ์๋ํ
์๋ ๊ฒ์ฆ
์คํค๋ง ๊ธฐ๋ฐ ๊ฒ์ฆ์๋ฌ ๋ฉ์์ง ์๋ ์์ฑ
ํ์ ์ถ๋ก
TypeScript ํ์
์์ฑ์๋์์ฑ ์ง์
ํ์ฅ ๊ฐ๋ฅ
์ปค์คํ
๊ฒ์ฆ ๊ท์น๋ณํ(Transform) ์ง์
Zod ์ค์น
๋ณต์ฌ
npm install zod
# or
pnpm add zod
Sonamu๋ Zod v4๋ฅผ ์ง์ํฉ๋๋ค. ์ต์ ๋ฒ์ ์ ์ค์นํ์ธ์.
๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ
Zod ์คํค๋ง ์ ์
๋ณต์ฌ
import { z } from "zod";
import { BaseModelClass, api } from "sonamu";
// Zod ์คํค๋ง ์ ์
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 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 }> {
// ์คํค๋ง๋ก ๊ฒ์ฆ
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 };
}
}
๊ฒ์ฆ ์๋ฌ ์ฒ๋ฆฌ
๋ณต์ฌ
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 {
// ๊ฒ์ฆ
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 ๊ฒ์ฆ ์๋ฌ
const messages = error.errors.map((e) => e.message).join(", ");
throw new Error(`Validation failed: ${messages}`);
}
throw error;
}
}
}
Zod ์คํค๋ง ํ์
์์ ํ์
๋ณต์ฌ
import { z } from "zod";
// ๋ฌธ์์ด
const StringSchema = z.string();
const EmailSchema = z.string().email();
const UrlSchema = z.string().url();
const UuidSchema = z.string().uuid();
// ์ซ์
const NumberSchema = z.number();
const IntSchema = z.number().int();
const PositiveSchema = z.number().positive();
const MinMaxSchema = z.number().min(0).max(100);
// ๋ถ๋ฆฌ์ธ
const BooleanSchema = z.boolean();
// ๋ ์ง
const DateSchema = z.date();
const DateStringSchema = z.string().datetime(); // ISO 8601
๊ฐ์ฒด
๋ณต์ฌ
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;
// };
// };
// }
๋ฐฐ์ด
๋ณต์ฌ
// ๋ฌธ์์ด ๋ฐฐ์ด
const StringArraySchema = z.array(z.string());
// ๊ฐ์ฒด ๋ฐฐ์ด
const UserArraySchema = z.array(
z.object({
id: z.number(),
name: z.string(),
})
);
// ์ต์/์ต๋ ๊ธธ์ด
const ArraySchema = z.array(z.string()).min(1).max(10);
// ๋น์ด์์ง ์์ ๋ฐฐ์ด
const NonEmptyArraySchema = z.array(z.string()).nonempty();
Enum๊ณผ Literal
๋ณต์ฌ
// Enum
const RoleSchema = z.enum(["admin", "manager", "normal"]);
type Role = z.infer<typeof RoleSchema>; // "admin" | "manager" | "normal"
// Literal (Zod v4์์ ์ฌ๋ฌ ๊ฐ ์ง์)
const StatusSchema = z.literal(["active", "inactive", "pending"]);
// Union
const RoleOrStatusSchema = z.union([
z.literal("admin"),
z.literal("user"),
z.literal("guest"),
]);
์ต์ ๋๊ณผ Nullable
๋ณต์ฌ
// ์ต์
๋ (undefined ํ์ฉ)
const OptionalSchema = z.string().optional();
// string | undefined
// Nullable (null ํ์ฉ)
const NullableSchema = z.string().nullable();
// string | null
// ๋ ๋ค ํ์ฉ
const NullishSchema = z.string().nullish();
// string | null | undefined
// ๊ธฐ๋ณธ๊ฐ
const DefaultSchema = z.string().default("default value");
์ค์ ์์
์ฌ์ฉ์ ๋ฑ๋ก
๋ณต์ฌ
const RegisterSchema = z.object({
email: z.string().email("์ ํจํ ์ด๋ฉ์ผ์ ์
๋ ฅํ์ธ์"),
username: z
.string()
.min(3, "์ฌ์ฉ์๋ช
์ ์ต์ 3์ ์ด์์ด์ด์ผ ํฉ๋๋ค")
.max(20, "์ฌ์ฉ์๋ช
์ ์ต๋ 20์๊น์ง ๊ฐ๋ฅํฉ๋๋ค")
.regex(/^[a-zA-Z0-9_]+$/, "์ฌ์ฉ์๋ช
์ ์๋ฌธ, ์ซ์, ์ธ๋์ค์ฝ์ด๋ง ์ฌ์ฉ ๊ฐ๋ฅํฉ๋๋ค"),
password: z
.string()
.min(8, "๋น๋ฐ๋ฒํธ๋ ์ต์ 8์ ์ด์์ด์ด์ผ ํฉ๋๋ค")
.regex(/[A-Z]/, "๋น๋ฐ๋ฒํธ์ ๋๋ฌธ์๊ฐ ์ต์ 1๊ฐ ํฌํจ๋์ด์ผ ํฉ๋๋ค")
.regex(/[a-z]/, "๋น๋ฐ๋ฒํธ์ ์๋ฌธ์๊ฐ ์ต์ 1๊ฐ ํฌํจ๋์ด์ผ ํฉ๋๋ค")
.regex(/[0-9]/, "๋น๋ฐ๋ฒํธ์ ์ซ์๊ฐ ์ต์ 1๊ฐ ํฌํจ๋์ด์ผ ํฉ๋๋ค"),
confirmPassword: z.string(),
agreeToTerms: z.boolean().refine((val) => val === true, {
message: "์ด์ฉ์ฝ๊ด์ ๋์ํด์ผ ํฉ๋๋ค",
}),
}).refine((data) => data.password === data.confirmPassword, {
message: "๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค",
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");
// confirmPassword ์ ์ธํ๊ณ ์ ์ฅ
const { confirmPassword, agreeToTerms, ...userData } = validated;
const [user] = await wdb
.table("users")
.insert({
...userData,
role: "normal",
})
.returning({ id: "id" });
return { userId: user.id };
}
}
ํ์ด์ง๋ค์ด์ ํ๋ผ๋ฏธํฐ
๋ณต์ฌ
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,
};
}
}
๋ ์ง ๋ฒ์ ๊ฒ์ฆ
๋ณต์ฌ
const DateRangeSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}).refine(
(data) => new Date(data.startDate) < new Date(data.endDate),
{
message: "์์์ผ์ ์ข
๋ฃ์ผ๋ณด๋ค ์ด์ ์ด์ด์ผ ํฉ๋๋ค",
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);
// ๋ฆฌํฌํธ ์์ฑ ๋ก์ง
// ...
}
}
Transform (๋ฐ์ดํฐ ๋ณํ)
๋ฌธ์์ด ๋ณํ
๋ณต์ฌ
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()),
});
์ซ์ ๋ณํ
๋ณต์ฌ
// ๋ฌธ์์ด์ ์ซ์๋ก ๋ณํ
const StringToNumberSchema = z.string().transform((val) => parseInt(val, 10));
// Coerce (๊ฐ์ ๋ณํ)
const CoerceNumberSchema = z.coerce.number();
// "123" โ 123
// true โ 1
// false โ 0
๋ ์ง ๋ณํ
๋ณต์ฌ
// ISO ๋ฌธ์์ด์ Date ๊ฐ์ฒด๋ก
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
๋ณต์กํ ๋ณํ
๋ณต์ฌ
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 {};
}
}),
});
// ์
๋ ฅ:
// {
// name: "Product",
// price: "29.99",
// tags: "electronics, gadget, new",
// metadata: '{"color": "blue", "size": "M"}'
// }
// ์ถ๋ ฅ:
// {
// name: "Product",
// price: 29.99,
// tags: ["electronics", "gadget", "new"],
// metadata: { color: "blue", size: "M" }
// }
์ปค์คํ ๊ฒ์ฆ
refine() ์ฌ์ฉ
๋ณต์ฌ
const PasswordSchema = z
.string()
.min(8)
.refine((val) => /[A-Z]/.test(val), {
message: "๋๋ฌธ์ ํ์",
})
.refine((val) => /[a-z]/.test(val), {
message: "์๋ฌธ์ ํ์",
})
.refine((val) => /[0-9]/.test(val), {
message: "์ซ์ ํ์",
});
// ๊ฐ์ฒด ์ ์ฒด ๊ฒ์ฆ
const UserSchema = z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "๋น๋ฐ๋ฒํธ ๋ถ์ผ์น",
path: ["confirmPassword"],
});
superRefine() ์ฌ์ฉ (๋ณต์กํ ๊ฒ์ฆ)
๋ณต์ฌ
const ComplexSchema = z
.object({
startDate: z.date(),
endDate: z.date(),
participants: z.array(z.string()),
})
.superRefine((data, ctx) => {
// ๋ ์ง ๊ฒ์ฆ
if (data.startDate >= data.endDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "์์์ผ์ ์ข
๋ฃ์ผ๋ณด๋ค ์ด์ ์ด์ด์ผ ํฉ๋๋ค",
path: ["endDate"],
});
}
// ์ฐธ๊ฐ์ ์ ๊ฒ์ฆ
if (data.participants.length < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "์ต์ 2๋ช
์ด์์ ์ฐธ๊ฐ์๊ฐ ํ์ํฉ๋๋ค",
path: ["participants"],
});
}
// ์ค๋ณต ๊ฒ์ฆ
const uniqueParticipants = new Set(data.participants);
if (uniqueParticipants.size !== data.participants.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "์ค๋ณต๋ ์ฐธ๊ฐ์๊ฐ ์์ต๋๋ค",
path: ["participants"],
});
}
});
safeParse (์์ ํ ๊ฒ์ฆ)
๋ณต์ฌ
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๋ ์์ธ๋ฅผ ๋์ง์ง ์์
const result = CreateUserSchema.safeParse(params);
if (!result.success) {
// ๊ฒ์ฆ ์คํจ
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 }));
}
// ๊ฒ์ฆ ์ฑ๊ณต
const validated = result.data;
const wdb = this.getPuri("w");
const [user] = await wdb
.table("users")
.insert(validated)
.returning({ id: "id" });
return { userId: user.id };
}
}
์๋ฌ ๋ฉ์์ง ์ปค์คํฐ๋ง์ด์ง
์ ์ญ ์๋ฌ ๋งต
๋ณต์ฌ
import { z } from "zod";
// ์๋ฌ ๋ฉ์์ง ์ปค์คํฐ๋ง์ด์ง
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") {
return { message: "๋ฌธ์์ด์ด์ด์ผ ํฉ๋๋ค" };
}
if (issue.expected === "number") {
return { message: "์ซ์์ฌ์ผ ํฉ๋๋ค" };
}
}
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === "string") {
return { message: `์ต์ ${issue.minimum}์ ์ด์์ด์ด์ผ ํฉ๋๋ค` };
}
}
return { message: ctx.defaultError };
});
๊ฐ๋ณ ์๋ฌ ๋ฉ์์ง
๋ณต์ฌ
const UserSchema = z.object({
email: z.string({
required_error: "์ด๋ฉ์ผ์ ํ์์
๋๋ค",
invalid_type_error: "์ด๋ฉ์ผ์ ๋ฌธ์์ด์ด์ด์ผ ํฉ๋๋ค",
}).email("์ ํจํ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค"),
age: z.number({
required_error: "๋์ด๋ ํ์์
๋๋ค",
invalid_type_error: "๋์ด๋ ์ซ์์ฌ์ผ ํฉ๋๋ค",
}).min(18, "18์ธ ์ด์์ด์ด์ผ ํฉ๋๋ค"),
});
์ฃผ์์ฌํญ
Zod ๊ฒ์ฆ ์ฌ์ฉ ์ ์ฃผ์์ฌํญ:
- parse()๋ ๊ฒ์ฆ ์คํจ ์ ์์ธ ๋ฐ์
- safeParse()๋ ๊ฒฐ๊ณผ ๊ฐ์ฒด ๋ฐํ
- Transform์ ๊ฒ์ฆ ํ ์คํ๋จ
- refine()์ ๋ง์ง๋ง์ ์คํ๋จ
- ์ฑ๋ฅ์ ์ํด ์คํค๋ง๋ฅผ ์ฌ์ฌ์ฉ