Zod — 런타임 검증과 타입 추론을 한 번에
Zod는 스키마를 정의하면 런타임 검증과 TypeScript 타입이 동시에 생성 되는 라이브러리입니다. "한 번만 정의하면 된다"가 핵심입니다.
왜 Zod가 필요한가
// 문제: TypeScript 타입은 런타임에 사라진다
interface User {
name: string;
age: number;
email: string;
}
// API에서 받은 데이터가 진짜 User인지 모른다
const data = await response.json(); // any 또는 unknown
// as User로 단언하면 런타임 검증 없이 그냥 믿는 것
TypeScript 타입은 컴파일 타임에만 존재하므로, **외부 데이터 **(API 응답, form 입력, 환경 변수)는 런타임 검증이 필요합니다.
기본 사용법
npm install zod
import { z } from 'zod';
// 스키마 정의 = 런타임 검증 + 타입 정의
const UserSchema = z.object({
name: z.string().min(1, '이름은 필수입니다'),
age: z.number().int().positive(),
email: z.string().email('유효한 이메일을 입력해주세요'),
});
// 스키마에서 TypeScript 타입 추출
type User = z.infer<typeof UserSchema>;
// { name: string; age: number; email: string }
// 런타임 검증
const result = UserSchema.safeParse({
name: '홍길동',
age: 25,
email: 'hong@test.com',
});
if (result.success) {
console.log(result.data); // User 타입으로 안전하게 사용
} else {
console.log(result.error.issues); // 검증 에러 상세
}
주요 스키마 타입
// 원시 타입
const str = z.string();
const num = z.number();
const bool = z.boolean();
const date = z.date();
// 리터럴
const role = z.literal('admin');
const zero = z.literal(0);
// enum
const Status = z.enum(['active', 'inactive', 'suspended']);
type Status = z.infer<typeof Status>; // 'active' | 'inactive' | 'suspended'
// 유니온
const StringOrNumber = z.union([z.string(), z.number()]);
// 배열
const Tags = z.array(z.string());
// 튜플
const Coord = z.tuple([z.number(), z.number()]);
// 선택적 / nullable
const OptionalName = z.string().optional(); // string | undefined
const NullableName = z.string().nullable(); // string | null
객체 스키마
const ProductSchema = z.object({
id: z.number(),
title: z.string().min(1).max(100),
price: z.number().positive(),
tags: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).optional(),
});
type Product = z.infer<typeof ProductSchema>;
// 확장
const DetailedProductSchema = ProductSchema.extend({
description: z.string(),
images: z.array(z.string().url()),
});
// 일부만 선택
const ProductSummary = ProductSchema.pick({ id: true, title: true });
// 일부 제외
const CreateProduct = ProductSchema.omit({ id: true });
// 모든 필드를 선택적으로
const UpdateProduct = ProductSchema.partial();
변환과 파이프
// coerce — 문자열을 숫자로 자동 변환
const NumericString = z.coerce.number();
NumericString.parse('42'); // 42 (number)
// transform — 값을 변환
const TrimmedString = z.string().transform((s) => s.trim());
TrimmedString.parse(' hello '); // 'hello'
// pipe — 변환 후 다시 검증
const StringToNumber = z.string()
.transform((s) => parseInt(s, 10))
.pipe(z.number().positive());
StringToNumber.parse('42'); // 42
// StringToNumber.parse('-1'); // Error: 양수가 아님
실전 패턴
API 응답 검증
const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
status: z.number(),
data: dataSchema,
message: z.string().optional(),
});
const UsersResponseSchema = ApiResponseSchema(z.array(UserSchema));
async function fetchUsers(): Promise<z.infer<typeof UsersResponseSchema>> {
const response = await fetch('/api/users');
const json = await response.json();
return UsersResponseSchema.parse(json); // 검증 실패 시 에러
}
환경 변수 검증
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
API_KEY: z.string().min(1),
});
// 앱 시작 시 한 번 검증
const env = EnvSchema.parse(process.env);
// env.PORT: number — coerce로 string → number 변환됨
폼 검증
const LoginSchema = z.object({
email: z.string().email('유효한 이메일을 입력해주세요'),
password: z.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.regex(/[A-Z]/, '대문자를 포함해야 합니다')
.regex(/[0-9]/, '숫자를 포함해야 합니다'),
});
type LoginForm = z.infer<typeof LoginSchema>;
function validateLogin(data: unknown): LoginForm | null {
const result = LoginSchema.safeParse(data);
if (result.success) return result.data;
console.log(result.error.flatten()); // 필드별 에러 메시지
return null;
}
parse vs safeParse
// parse — 실패 시 ZodError를 던짐
try {
const user = UserSchema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues);
}
}
// safeParse — 에러를 던지지 않고 Result 형태로 반환
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data);
} else {
console.log(result.error.issues);
}
공부하다 보니 safeParse가 Result 패턴과 거의 동일하더라고요. 에러를 던지지 않으므로 더 안전합니다.
정리
- Zod는 스키마 정의 한 번으로 런타임 검증과 TypeScript 타입을 동시에 생성한다
z.infer<typeof Schema>로 스키마에서 타입을 추출한다safeParse는 에러를 던지지 않고 Result 형태로 반환한다- API 응답, 환경 변수, 폼 입력 등 외부 데이터 검증에 필수적이다
coerce,transform,pipe로 데이터 변환도 처리할 수 있다
댓글 로딩 중...