Zodλ?
νμ μμ μ€ν€λ§
TypeScript νμ
μλ μΆλ‘ λ³λ νμ
μ μ λΆνμ
λ°νμ κ²μ¦
μ€ν μ λ°μ΄ν° κ²μ¦ μλͺ»λ λ°μ΄ν° μ°¨λ¨
μμΈν μλ¬
νλλ³ μλ¬ λ©μμ§ λλ²κΉ
μ©μ΄
λ³ν & κΈ°λ³Έκ°
λ°μ΄ν° λ³ν λ° κΈ°λ³Έκ° μ μ°ν μ²λ¦¬
κΈ°λ³Έ μ¬μ©λ²
Zod μ€ν€λ§λ Entityμμ μλ μμ±λμ§λ§, μ§μ μ μν μλ μμ΅λλ€.μ€ν€λ§ μ μ
import { z } from "zod";
// Entityλ‘λΆν° μλ μμ±
export const User = z.object({
id: z.number().int(),
email: z.string().max(100),
age: z.number().int(),
role: z.enum(["admin", "normal"]),
});
export type User = z.infer<typeof User>;
import { z } from "zod";
// 컀μ€ν
μ€ν€λ§
export const LoginParams = z.object({
email: z.string().email("μ ν¨ν μ΄λ©μΌμ μ
λ ₯νμΈμ"),
password: z.string().min(8, "λΉλ°λ²νΈλ μ΅μ 8μ μ΄μ"),
rememberMe: z.boolean().optional(),
});
export type LoginParams = z.infer<typeof LoginParams>;
λ°μ΄ν° κ²μ¦
import { User } from "./user.types";
try {
const user = User.parse({
id: 1,
email: "john@test.com",
age: 30,
role: "admin",
});
// β
κ²μ¦ μ±κ³΅, userλ User νμ
console.log(user.email);
} catch (error) {
// β κ²μ¦ μ€ν¨ μ ZodError
console.error(error);
}
import { User } from "./user.types";
const result = User.safeParse({
id: 1,
email: "invalid-email",
age: "30",
role: "guest",
});
if (result.success) {
// β
κ²μ¦ μ±κ³΅
console.log(result.data.email);
} else {
// β κ²μ¦ μ€ν¨
console.error(result.error.issues);
// [
// { path: ["email"], message: "Invalid email" },
// { path: ["age"], message: "Expected number, received string" },
// { path: ["role"], message: "Invalid enum value" }
// ]
}
parse vs safeParse:
parse(): μλ¬λ₯Ό λμ§λλ€. try-catchλ‘ μ²λ¦¬safeParse(): μλ¬λ₯Ό λ°νν©λλ€. result.successλ‘ μ²΄ν¬
parse()λ₯Ό μ¬μ©νμ¬ μλμΌλ‘ 400 μλ¬λ₯Ό λ°ννκ³ , UIμμλ safeParse()λ‘ μμ νκ² μ²λ¦¬ν©λλ€.κΈ°λ³Έ νμ κ²μ¦
Entityμ κ° νμ λ³ Zod κ²μ¦μ λλ€.λ¬Έμμ΄ κ²μ¦
const email = z.string();
email.parse("john@test.com"); // β
const withLength = z.string().max(100);
withLength.parse("a".repeat(100)); // β
withLength.parse("a".repeat(101)); // β
const email = z.string().email();
email.parse("john@test.com"); // β
email.parse("invalid-email"); // β
const url = z.string().url();
url.parse("https://example.com"); // β
url.parse("not-a-url"); // β
const uuid = z.string().uuid();
uuid.parse("550e8400-e29b-41d4-a716-446655440000"); // β
uuid.parse("invalid-uuid"); // β
const username = z.string().regex(/^[a-zA-Z0-9_]+$/);
username.parse("john_doe"); // β
username.parse("john@doe"); // β
μ«μ κ²μ¦
const age = z.number().int();
age.parse(30); // β
age.parse(30.5); // β Must be integer
const age = z.number().int().min(0).max(120);
age.parse(30); // β
age.parse(-1); // β Number must be greater than or equal to 0
age.parse(150); // β Number must be less than or equal to 120
const price = z.number().positive();
price.parse(100); // β
price.parse(0); // β
price.parse(-10); // β
const count = z.number().nonnegative();
count.parse(0); // β
count.parse(10); // β
count.parse(-1); // β
λ μ§ κ²μ¦
const birthDate = z.date();
birthDate.parse(new Date()); // β
birthDate.parse("2024-01-01"); // β Expected Date, received string
const birthDate = z.date().min(new Date("1900-01-01")).max(new Date());
birthDate.parse(new Date("2000-01-01")); // β
birthDate.parse(new Date("1800-01-01")); // β
// APIμμ μμ£Ό μ¬μ©
const dateSchema = z
.string()
.datetime()
.transform((val) => new Date(val));
dateSchema.parse("2024-01-01T00:00:00.000Z"); // β
Date κ°μ²΄ λ°ν
λΆλ¦° κ²μ¦
const isActive = z.boolean();
isActive.parse(true); // β
isActive.parse(false); // β
isActive.parse("true"); // β Expected boolean, received string
// λ³ν: λ¬Έμμ΄ β λΆλ¦°
const coerced = z.coerce.boolean();
coerced.parse("true"); // β
true
coerced.parse("false"); // β
false
coerced.parse(1); // β
true
coerced.parse(0); // β
false
λ³΅ν© νμ κ²μ¦
λ°°μ΄
const tags = z.string().array();
tags.parse(["typescript", "nodejs"]); // β
tags.parse(["typescript", 123]); // β
const tags = z.string().array().min(1).max(5);
tags.parse(["tag1"]); // β
tags.parse([]); // β Array must contain at least 1 element(s)
tags.parse(["tag1", "tag2", "tag3", "tag4", "tag5", "tag6"]); // β
const tags = z.string().array().nonempty();
tags.parse(["tag1"]); // β
tags.parse([]); // β Array must contain at least 1 element(s)
κ°μ²΄
const user = z.object({
name: z.string(),
profile: z.object({
bio: z.string(),
website: z.string().url().optional(),
}),
});
user.parse({
name: "John",
profile: {
bio: "Hello",
website: "https://john.com",
},
}); // β
const user = z.object({
name: z.string(),
nickname: z.string().optional(), // undefined νμ©
bio: z.string().nullable(), // null νμ©
});
user.parse({ name: "John" }); // β
(nicknameμ undefined)
user.parse({ name: "John", nickname: "JD" }); // β
user.parse({ name: "John", bio: null }); // β
Enum
const role = z.enum(["admin", "moderator", "normal"]);
role.parse("admin"); // β
role.parse("guest"); // β Invalid enum value
const roles = z.enum(["admin", "moderator", "normal"]).array();
roles.parse(["admin", "normal"]); // β
roles.parse(["admin", "guest"]); // β
Union (μ¬λ¬ νμ μ€ νλ)
const idOrEmail = z.union([z.number().int(), z.string().email()]);
idOrEmail.parse(123); // β
idOrEmail.parse("john@test.com"); // β
idOrEmail.parse("not-an-email"); // β
κ³ κΈ κ²μ¦ ν¨ν΄
μ‘°κ±΄λΆ κ²μ¦
const password = z.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val),
{ message: "λλ¬Έμλ₯Ό ν¬ν¨ν΄μΌ ν©λλ€" }
)
.refine(
(val) => /[0-9]/.test(val),
{ message: "μ«μλ₯Ό ν¬ν¨ν΄μΌ ν©λλ€" }
)
.refine(
(val) => /[!@#$%^&*]/.test(val),
{ message: "νΉμλ¬Έμλ₯Ό ν¬ν¨ν΄μΌ ν©λλ€" }
);
password.parse("Abc12345!"); // β
password.parse("abc12345"); // β λλ¬Έμ μμ
const user = z.object({
password: z.string(),
passwordConfirm: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.passwordConfirm) {
ctx.addIssue({
code: "custom",
message: "λΉλ°λ²νΈκ° μΌμΉνμ§ μμ΅λλ€",
path: ["passwordConfirm"],
});
}
if (data.password.length < 8) {
ctx.addIssue({
code: "custom",
message: "λΉλ°λ²νΈλ 8μ μ΄μμ΄μ΄μΌ ν©λλ€",
path: ["password"],
});
}
});
λ°μ΄ν° λ³ν
const age = z.string().transform(val => parseInt(val, 10));
age.parse("30"); // β
30 (number)
const email = z.preprocess(
(val) => String(val).toLowerCase().trim(),
z.string().email()
);
email.parse(" JOHN@TEST.COM "); // β
"john@test.com"
const age = z.coerce.number();
age.parse("30"); // β
30 (numberλ‘ λ³ν)
age.parse("abc"); // β NaNμ numberλ‘ ν΅κ³Όνμ§λ§ κ²μ¦ μ€ν¨
κΈ°λ³Έκ° μ€μ
const settings = z.object({
theme: z.enum(["light", "dark"]).default("light"),
notifications: z.boolean().default(true),
});
settings.parse({}); // β
{ theme: "light", notifications: true }
settings.parse({ theme: "dark" }); // β
{ theme: "dark", notifications: true }
const count = z.number().catch(0);
count.parse(10); // β
10
count.parse("invalid"); // β
0 (μλ¬ λμ κΈ°λ³Έκ°)
μ€μ κ²μ¦ ν¨ν΄
API νλΌλ―Έν° κ²μ¦
export const UserListParams = z.object({
num: z.number().int().min(1).max(100).default(24),
page: z.number().int().min(1).default(1),
search: z.enum(["id", "email", "username"]).optional(),
keyword: z.string().trim().optional(),
orderBy: z.enum(["id-desc", "id-asc", "created_at-desc"]).optional(),
}).refine(
(data) => {
// keywordκ° μμΌλ©΄ searchλ νμ
if (data.keyword && !data.search) {
return false;
}
return true;
},
{
message: "keywordμ searchλ ν¨κ» μ¬μ©ν΄μΌ ν©λλ€",
path: ["search"],
}
);
export const UserSaveParams = z
.object({
id: z.number().int().optional(),
email: z.string().email().max(100),
username: z
.string()
.min(2)
.max(50)
.regex(/^[a-zA-Z0-9_]+$/),
password: z.string().min(8).optional(),
role: z.enum(["admin", "normal"]).default("normal"),
})
.refine(
(data) => {
// μ μ¬μ©μλ©΄ password νμ
if (!data.id && !data.password) {
return false;
}
return true;
},
{
message: "μ μ¬μ©μλ λΉλ°λ²νΈκ° νμν©λλ€",
path: ["password"],
},
);
λΉμ¦λμ€ λ‘μ§ κ²μ¦
const dateRange = z.object({
startDate: z.date(),
endDate: z.date(),
}).refine(
(data) => data.endDate > data.startDate,
{
message: "μ’
λ£μΌμ μμμΌλ³΄λ€ μ΄νμ¬μΌ ν©λλ€",
path: ["endDate"],
}
).refine(
(data) => {
const diff = data.endDate.getTime() - data.startDate.getTime();
const days = diff / (1000 * 60 * 60 * 24);
return days <= 365;
},
{
message: "κΈ°κ°μ μ΅λ 1λ
κΉμ§λ§ κ°λ₯ν©λλ€",
path: ["endDate"],
}
);
const payment = z
.object({
amount: z.number().positive(),
discount: z.number().nonnegative().optional(),
})
.refine(
(data) => {
if (data.discount && data.discount > data.amount) {
return false;
}
return true;
},
{
message: "ν μΈ κΈμ‘μ κ²°μ κΈμ‘λ³΄λ€ ν΄ μ μμ΅λλ€",
path: ["discount"],
},
);
Form κ²μ¦
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserSaveParams } from "@/services/user/user.types";
function UserForm() {
const form = useForm({
resolver: zodResolver(UserSaveParams),
defaultValues: {
email: "",
username: "",
role: "normal",
},
});
const onSubmit = form.handleSubmit((data) => {
// dataλ μλμΌλ‘ UserSaveParams νμ
console.log(data);
});
return (
<form onSubmit={onSubmit}>
<input {...form.register("email")} />
{form.formState.errors.email && <span>{form.formState.errors.email.message}</span>}
<input {...form.register("username")} />
{form.formState.errors.username && <span>{form.formState.errors.username.message}</span>}
<button type="submit">μ μ₯</button>
</form>
);
}
μλ¬ μ²λ¦¬
ZodError ꡬ쑰
try {
User.parse(invalidData);
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues);
// [
// {
// code: "invalid_type",
// expected: "string",
// received: "number",
// path: ["email"],
// message: "Expected string, received number"
// },
// {
// code: "too_small",
// minimum: 8,
// type: "string",
// inclusive: true,
// path: ["password"],
// message: "String must contain at least 8 character(s)"
// }
// ]
}
}
μλ¬ λ©μμ§ μ»€μ€ν°λ§μ΄μ§
const user = z.object({
email: z.string().email("μ ν¨ν μ΄λ©μΌ μ£Όμλ₯Ό μ
λ ₯νμΈμ"),
age: z.number()
.int("λμ΄λ μ μμ¬μΌ ν©λλ€")
.min(0, "λμ΄λ 0 μ΄μμ΄μ΄μΌ ν©λλ€")
.max(120, "λμ΄λ 120 μ΄νμ¬μΌ ν©λλ€"),
});
import { z } from "zod";
// νκ΅μ΄ μλ¬ λ©μμ§
const customErrorMap: z.ZodErrorMap = (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) {
return { message: `μ΅μ ${issue.minimum}μ μ΄μ μ
λ ₯ν΄μ£ΌμΈμ` };
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
μλ¬ ν¬λ§·ν
const result = User.safeParse(invalidData);
if (!result.success) {
// Flat μλ¬ (νλλ³)
const flatErrors = result.error.flatten();
console.log(flatErrors.fieldErrors);
// {
// email: ["Invalid email"],
// age: ["Number must be greater than 0"]
// }
// Form μλ¬ (React Hook Form νμ)
const formErrors = result.error.format();
console.log(formErrors.email?._errors); // ["Invalid email"]
}
μ±λ₯ μ΅μ ν
μ€ν€λ§ μ¬μ¬μ©
// β λ§€λ² μλ‘ μμ± (λλ¦Ό)
function validate(data: unknown) {
const schema = z.object({
name: z.string(),
age: z.number(),
});
return schema.parse(data);
}
// β
μ€ν€λ§ μ¬μ¬μ© (λΉ λ¦)
const schema = z.object({
name: z.string(),
age: z.number(),
});
function validate(data: unknown) {
return schema.parse(data);
}
λΆλΆ κ²μ¦
// μ 체 κ²μ¦ λΆνμ μ λΆλΆ κ²μ¦
const user = z.object({
id: z.number(),
email: z.string().email(),
profile: z.object({
bio: z.string(),
website: z.string().url(),
}),
});
// μ΄λ©μΌλ§ κ²μ¦
const emailOnly = user.pick({ email: true });
emailOnly.parse({ email: "john@test.com" }); // β
λΉ λ¦
// profileλ§ κ²μ¦
const profileOnly = user.pick({ profile: true });
λ€μ λ¨κ³
E2E Type Safety
μλν¬μλ νμ
μμ μ±
Entity Types
Entity νμ
λ³ν
Generated Types
μμ± νμ
νμ©
Model Testing
Zod κ²μ¦ ν
μ€νΈνκΈ°