
어제 Zod 기본 정리를 하면서
preprocess, custom, transform, pipe 같은 API를 처음 봤을 때는
“다 비슷한 역할 아닌가?”라는 생각이 들었다.
처음엔
-> 그냥 취향껏 하나 골라 쓰는 건가?
-> 다 커스텀하는 방법 아닌가?
하지만 더 알아보니 각각 사용하는 의미와 목적이 완전히 달랐다.
이번 글에서는 이 차이를 명확하게 정리해보려고 한다.
// 1. 스키마 전처리
const pre1 = z.preprocess(val => {
return typeof val === 'string' ? val.toUpperCase() : val
}, z.string())
pre1.parse('abc') // 'ABC'
pre1.parse(123) // ❌
// 2. 스키마 사용자 정의
const cus1 = z.custom(val => {
return typeof val === 'string' ? /[A-Z]/.test(val) : false
})
cus1.parse('ABC') // 'ABC'
cus1.parse(123) // ❌
// 3. transform 단독 사용
const tra1 = z.transform(val => {
return typeof val === 'string' ? val.toUpperCase() : val
})
tra1.parse('abc') // 'ABC'
tra1.parse(123) // 123 ❗
// 4. schema.transform
const tra2 = z.string().transform(val => val.toUpperCase())
tra2.parse('abc') // 'ABC'
tra2.parse(123) // ❌
// 5. pipe
const pip1 = z.pipe(
z.string(),
z.transform(val => val.length)
)
pip1.parse('abc') // 3
pip1.parse(123) // ❌
const pip2 = z.string().pipe(
z.transform(val => val.length)
)
pip2.parse('abc') // 3
pip2.parse(123) // ❌
-> 검증 이전에 개입
z.preprocess(fn, schema)
z.preprocess(v => Number(v), z.number())
-> 검증만 직접 정의
z.custom(validator)
z.transform(fn)
이건 사실상 비추천이다.
tra1.parse(123) // 123 ❗
-> 검증 후 변환 (정석)
z.string().transform(...)
z.string()
.min(1)
.transform(v => v.toUpperCase())
📌 가장 많이 쓰는 패턴이라고 한다
-> 스키마 -> 스키마 연결
z.string().pipe(z.number())
z.string().pipe(
z.transform(v => v.length)
)
| API | 검증 전 | 검증 | 변환 | 타입 안전 | 주 용도 |
|---|---|---|---|---|---|
| preprocess | ✅ | ❌ | ✅ | ❌ | 입력 정리 |
| custom | ❌ | ✅ | ❌ | ❌ | 특수 검증 |
| transform (단독) | ❌ | ❌ | ✅ | ❌ | ❌ 거의 안 씀 |
| schema.transform | ❌ | ✅ | ✅ | ✅ | 정석 |
| pipe | ❌ | ✅ | ✅ | ✅ | 단계 분리 |
z.string().refine(v => v.length > 3)
z.object({
password: z.string(),
confirm: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirm) {
ctx.addIssue({
path: ['confirm'],
message: '비밀번호가 일치하지 않습니다',
code: z.ZodIssueCode.custom,
})
}
})
📌 필드 간 관계 검증은 superRefine
처음에는 다 비슷해 보였던 Zod API들이 알고 보니 각자 명확한 책임과 쓰임새를 가지고 있었다.
출처