Zod 복잡한 폼 검증 메서드 완벽 가이드
1. transform - 데이터 변환
검증 후 데이터를 원하는 형태로 변환할 때 사용합니다.
// 문자열을 숫자로 변환
const ageSchema = z
.string()
.transform((val) => parseInt(val, 10))
.refine((val) => !isNaN(val), '숫자를 입력해주세요')
// 날짜 문자열을 Date 객체로
const dateSchema = z.string().transform((str) => new Date(str))
// 여러 단계 변환
const priceSchema = z
.string()
.transform((val) => val.replace(/,/g, '')) // 쉼표 제거
.transform((val) => parseFloat(val)) // 숫자 변환
.refine((val) => val > 0, '0보다 커야 합니다')
2. preprocess - 검증 전 전처리
검증 전에 데이터를 정제하거나 변환할 때 사용합니다.
// 공백 제거 후 검증
const trimmedString = z.preprocess(
(val) => (typeof val === 'string' ? val.trim() : val),
z.string().min(1, '필수 입력입니다'),
)
// 빈 문자열을 null로 변환
const optionalEmail = z.preprocess(
(val) => (val === '' ? null : val),
z.string().email().nullable(),
)
// 숫자 문자열을 자동으로 숫자로
const numberFromString = z.preprocess(
(val) => (typeof val === 'string' ? Number(val) : val),
z.number().positive(),
)
3. discriminatedUnion - 조건부 스키마
특정 필드 값에 따라 다른 스키마를 적용할 때 사용합니다.
const paymentSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('card'),
cardNumber: z.string().length(16),
cvv: z.string().length(3),
}),
z.object({
method: z.literal('bank'),
accountNumber: z.string(),
bankName: z.string(),
}),
z.object({
method: z.literal('cash'),
// 추가 필드 없음
}),
])
// 사용 예시
// method가 'card'면 cardNumber, cvv 필수
// method가 'bank'면 accountNumber, bankName 필수
4. pipe - 스키마 체이닝
여러 스키마를 순차적으로 적용할 때 사용합니다.
// 문자열 검증 후 변환, 그 후 숫자 검증
const schema = z
.string()
.min(1)
.pipe(z.coerce.number())
.pipe(z.number().min(0).max(100))
// 날짜 변환 후 검증
const futureDate = z
.string()
.pipe(z.coerce.date())
.pipe(z.date().min(new Date(), '미래 날짜여야 합니다'))
5. partial / required / pick / omit - 스키마 조작
기존 스키마를 부분적으로 수정할 때 사용합니다.
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number(),
phone: z.string(),
})
// 모든 필드를 optional로
const partialUser = userSchema.partial()
// { name?: string, email?: string, ... }
// 특정 필드만 optional로
const partialEmail = userSchema.partial({ email: true })
// 모든 필드를 required로 (optional을 제거)
const requiredUser = userSchema.required()
// 특정 필드만 선택
const loginSchema = userSchema.pick({ email: true, password: true })
// 특정 필드 제외
const publicUser = userSchema.omit({ password: true })
6. merge / extend - 스키마 합성
여러 스키마를 결합할 때 사용합니다.
const baseUser = z.object({
name: z.string(),
email: z.string().email(),
})
const timestamps = z.object({
createdAt: z.date(),
updatedAt: z.date(),
})
// 두 스키마 병합
const userWithTimestamps = baseUser.merge(timestamps)
// extend는 merge의 별칭
const extendedUser = baseUser.extend({
age: z.number(),
phone: z.string(),
})
7. passthrough / strict / strip - 추가 속성 처리
스키마에 정의되지 않은 필드를 어떻게 처리할지 결정합니다.
const strictSchema = z
.object({
name: z.string(),
})
.strict() // 추가 필드 있으면 에러
const passthroughSchema = z
.object({
name: z.string(),
})
.passthrough() // 추가 필드 허용, 통과
const stripSchema = z
.object({
name: z.string(),
})
.strip() // 추가 필드 제거 (기본값)
8. catchall - 동적 키 검증
키를 미리 알 수 없는 객체를 검증할 때 사용합니다.
// 모든 추가 필드는 문자열이어야 함
const dynamicSchema = z
.object({
id: z.number(),
})
.catchall(z.string())
// 사용 예시: { id: 1, anyKey: "value", anotherKey: "value2" }
// 실전 예시: 다국어 지원
const i18nSchema = z
.object({
id: z.string(),
})
.catchall(z.string()) // ko, en, ja 등 동적 언어 키
9. lazy - 재귀적 스키마
자기 자신을 참조하는 스키마를 만들 때 사용합니다.
// 트리 구조
type Category = {
name: string
subcategories?: Category[]
}
const categorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(categorySchema).optional(),
}),
)
// 댓글과 대댓글
const commentSchema: z.ZodType<any> = z.lazy(() =>
z.object({
text: z.string(),
replies: z.array(commentSchema).optional(),
}),
)
10. brand - 타입 브랜딩
같은 타입이지만 의미적으로 구분이 필요할 때 사용합니다.
const userId = z.string().brand<'UserId'>()
const productId = z.string().brand<'ProductId'>()
type UserId = z.infer<typeof userId>
type ProductId = z.infer<typeof productId>
// TypeScript 레벨에서 구분됨
function getUser(id: UserId) {
/* ... */
}
function getProduct(id: ProductId) {
/* ... */
}
// getUser(productId); // 타입 에러!
실전 조합 예시
복잡한 회원가입 폼에서 여러 메서드를 조합한 예시입니다.
// 복잡한 회원가입 폼
const signupSchema = z
.object({
// 전처리 + 검증
email: z.preprocess(
(val) => (typeof val === 'string' ? val.trim().toLowerCase() : val),
z.string().email('올바른 이메일을 입력해주세요'),
),
// 변환 + 검증
age: z
.string()
.transform((val) => parseInt(val, 10))
.refine((val) => !isNaN(val) && val >= 18, '18세 이상이어야 합니다'),
password: z.string().min(8),
confirmPassword: z.string(),
// 조건부 필드
accountType: z.enum(['personal', 'business']),
})
.and(
z.discriminatedUnion('accountType', [
z.object({
accountType: z.literal('personal'),
firstName: z.string(),
lastName: z.string(),
}),
z.object({
accountType: z.literal('business'),
companyName: z.string(),
businessNumber: z.string(),
}),
]),
)
.refine((data) => data.password === data.confirmPassword, {
message: '비밀번호가 일치하지 않습니다',
path: ['confirmPassword'],
})
마치며
이런 메서드들을 조합하면 거의 모든 복잡한 폼 검증을 처리할 수 있습니다!