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()μ λ§μ§λ§μ μ€νλ¨
- μ±λ₯μ μν΄ μ€ν€λ§λ₯Ό μ¬μ¬μ©