TypeScript는 컴파일 타임에 타입을 검사합니다. 그러나 외부 API 응답, 폼 입력값, 환경 변수처럼 런타임에 외부에서 들어오는 데이터는 TypeScript가 보증하지 못합니다. 컴파일이 끝나면 타입 정보는 사라지기 때문입니다.
Zod는 이 문제를 해결합니다. 스키마를 한 번 정의하면 런타임 검증과 TypeScript 타입 추론을 동시에 얻을 수 있습니다. 별도의 타입 선언이 필요 없으며, 스키마가 곧 타입의 단일 진실 공급원(Single Source of Truth)이 됩니다.
npm install zod
TypeScript 4.5 이상, strict 모드 활성화가 권장됩니다.
Schema: 데이터의 형태와 제약 조건을 선언적으로 정의하는 Zod 객체
Zod의 모든 것은 스키마에서 시작합니다. z.string, z.number, z.object 등 빌트인 메서드로 스키마를 구성합니다.
import { z } from "zod";
// 원시 타입 스키마
const nameSchema = z.string();
const ageSchema = z.number().int().min(0).max(150);
const isActiveSchema = z.boolean();
// 객체 스키마
const userSchema = z.object({
id: z.number(),
name: z.string().min(1).max(50),
email: z.string().email(),
age: z.number().int().positive().optional(),
createdAt: z.string().datetime(),
});
// 배열 스키마
const tagsSchema = z.array(z.string()).min(1);
// 중첩 스키마
const postSchema = z.object({
title: z.string(),
author: userSchema,
tags: tagsSchema,
});
스키마는 체이닝으로 제약 조건을 추가합니다. z.string.email.min(5)처럼 메서드를 이어 붙이면 복수의 검증 규칙이 모두 적용됩니다.
.parse는 데이터를 검증합니다. 검증을 통과하면 타입이 좁혀진 데이터를 반환하고, 실패하면 ZodError를 던집니다.
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
});
// 성공
const user = userSchema.parse({ name: "김철수", email: "chul@example.com" });
// user의 타입: { name: string; email: string }
// 실패 — ZodError 예외 발생
try {
userSchema.parse({ name: "김철수", email: "이메일아님" });
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.errors);
// [{ path: ["email"], message: "Invalid email", ... }]
}
}
ZodError의 .errors 배열에는 어느 경로(path)에서 어떤 문제(message)가 발생했는지 상세히 담겨 있습니다. 여러 필드가 동시에 실패하면 모든 오류를 한 번에 반환합니다.
예외 처리가 번거로운 경우 .safeParse를 사용합니다. 결과 객체의 success 필드로 분기하면 타입 가드가 자동으로 작동합니다.
const result = userSchema.safeParse({ name: "김철수", email: "wrong" });
if (result.success) {
// result.data의 타입이 자동으로 좁혀짐
console.log(result.data.name);
} else {
// result.error는 ZodError 인스턴스
console.log(result.error.errors);
}
API 라우트나 폼 핸들러처럼 오류를 예외가 아닌 값으로 처리하고 싶을 때 적합합니다.
// 폼 제출 핸들러 예시
async function handleSubmit(formData: unknown) {
const result = formSchema.safeParse(formData);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await saveUser(result.data); // 타입 안전
}
.flatten은 fieldErrors와 formErrors로 구분된 오류 객체를 반환하여 폼 라이브러리와의 연동에 편리합니다.
스키마를 작성했다면 별도의 타입 선언은 필요 없습니다. z.infer로 스키마에서 타입을 추론합니다.
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
});
// 타입을 별도로 선언할 필요 없음
type User = z.infer<typeof userSchema>;
// 동일한 결과:
// type User = {
// id: number;
// name: string;
// email: string;
// role: "admin" | "user";
// }
function processUser(user: User) {
console.log(user.role); // "admin" | "user"
}
스키마를 수정하면 타입도 자동으로 업데이트됩니다. 타입과 스키마를 따로 관리하다가 불일치가 생기는 문제를 원천 차단합니다.
.transform은 검증 후 데이터를 가공합니다. 입력 타입과 출력 타입이 달라질 수 있습니다.
// 문자열을 숫자로 변환
const numericStringSchema = z.string().transform((val) => parseInt(val, 10));
// 입력: string, 출력: number
const result = numericStringSchema.parse("42"); // 42 (number)
// 날짜 문자열을 Date 객체로 변환
const dateSchema = z.string().datetime().transform((val) => new Date(val));
// 입력: string, 출력: Date
// 객체 필드 변환
const userSchema = z.object({
firstName: z.string(),
lastName: z.string(),
birthYear: z.string().transform(Number),
}).transform((data) => ({
fullName: `${data.firstName} ${data.lastName}`,
birthYear: data.birthYear,
}));
type TransformedUser = z.infer<typeof userSchema>;
// { fullName: string; birthYear: number }
z.infer는 변환 후 타입을 추론합니다. 입력 타입이 필요하면 z.input<typeof schema>를 사용합니다.
비밀번호 일치 확인, 날짜 범위 검증처럼 복잡한 규칙은 .refine으로 구현합니다.
// 단일 필드 커스텀 검증
const passwordSchema = z.string()
.min(8, "비밀번호는 8자 이상이어야 합니다")
.refine(
(val) => /[A-Z]/.test(val),
"대문자를 하나 이상 포함해야 합니다"
)
.refine(
(val) => /[0-9]/.test(val),
"숫자를 하나 이상 포함해야 합니다"
);
// 복수 필드 간 검증 — superRefine 또는 object().refine()
const signUpSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "비밀번호가 일치하지 않습니다",
path: ["confirmPassword"], // 오류가 표시될 필드 경로
}
);
// superRefine: 세밀한 오류 제어
const rangeSchema = z.object({
start: z.number(),
end: z.number(),
}).superRefine((data, ctx) => {
if (data.start >= data.end) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "start는 end보다 작아야 합니다",
path: ["start"],
});
}
});
.refine은 단일 오류, .superRefine은 복수 오류를 추가할 수 있습니다.
z.union은 TypeScript의 유니온 타입(A | B)과 대응합니다. 나열된 스키마 중 하나라도 통과하면 검증에 성공합니다.
// 문자열 또는 숫자
const stringOrNumber = z.union([z.string(), z.number()]);
stringOrNumber.parse("hello"); // ok
stringOrNumber.parse(42); // ok
stringOrNumber.parse(true); // ZodError
// 복잡한 유니온
const responseSchema = z.union([
z.object({ status: z.literal("success"), data: z.string() }),
z.object({ status: z.literal("error"), message: z.string() }),
]);
type Response = z.infer<typeof responseSchema>;
// { status: "success"; data: string } | { status: "error"; message: string }
단축 표현으로 .or을 사용할 수도 있습니다.
const schema = z.string().or(z.number());
// z.union([z.string(), z.number()])과 동일
Discriminated Union: 공통 식별 필드를 기준으로 타입을 구분하는 최적화된 유니온 스키마
z.union은 각 스키마를 순서대로 시도합니다. 스키마가 많을수록 성능이 저하될 수 있습니다. z.discriminatedUnion은 식별자 필드를 먼저 확인하여 즉시 해당 스키마로 분기합니다.
// z.union() 방식 — 모든 스키마를 순서대로 시도
const shapeUnion = z.union([
z.object({ kind: z.literal("circle"), radius: z.number() }),
z.object({ kind: z.literal("rect"), width: z.number(), height: z.number() }),
z.object({ kind: z.literal("triangle"), base: z.number(), height: z.number() }),
]);
// z.discriminatedUnion() 방식 — kind 필드로 즉시 분기
const shapeSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("circle"), radius: z.number() }),
z.object({ kind: z.literal("rect"), width: z.number(), height: z.number() }),
z.object({ kind: z.literal("triangle"), base: z.number(), height: z.number() }),
]);
const shape = shapeSchema.parse({ kind: "circle", radius: 10 });
type Shape = z.infer<typeof shapeSchema>;
Input data
|
v
kind === "circle" --> circle schema
kind === "rect" --> rect schema
kind === "triangle"--> triangle schema
|
v
No match: ZodError
Redux action, API 응답, 이벤트 시스템처럼 타입 식별자가 명확한 경우에 z.discriminatedUnion이 적합합니다. 잘못된 식별자 값을 입력하면 다른 스키마를 시도하지 않고 즉시 오류를 반환합니다.
외부 API 응답을 그대로 믿는 것은 위험합니다. Zod로 경계에서 검증합니다.
const productSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number().nonnegative(),
category: z.enum(["electronics", "clothing", "food"]),
tags: z.array(z.string()).default([]),
});
type Product = z.infer<typeof productSchema>;
async function fetchProduct(id: number): Promise<Product> {
const res = await fetch(`/api/products/${id}`);
const json = await res.json();
// 런타임 검증 — 이후 코드에서 Product 타입 보장
return productSchema.parse(json);
}
.default([])처럼 기본값을 설정하면 누락된 필드를 안전하게 처리할 수 있습니다.
Zod는 런타임과 컴파일 타임의 간극을 스키마 하나로 메웁니다.
| 메서드 | 역할 |
|---|---|
z.*() | 스키마 정의 |
.parse | 검증 후 타입 반환, 실패 시 예외 |
.safeParse | 검증 후 결과 객체 반환, 예외 없음 |
z.infer<> | 스키마에서 TypeScript 타입 추론 |
.transform | 검증 후 데이터 변환 |
.refine | 커스텀 유효성 검사 추가 |
z.union | 여러 스키마 중 하나 선택 |
z.discriminatedUnion | 식별 필드 기반 최적화 유니온 |
스키마를 외부 데이터가 진입하는 경계(API 레이어, 폼 핸들러, 환경 변수 로딩)에 배치하면, 애플리케이션 내부는 타입 안전성이 보장된 상태로 유지됩니다.