한 줄 정의
z.preprocess는 검증을 시작하기 전에 입력을 먼저 손보는 스키마다. 가공 함수를 하나 주면, 그 함수가 손대지 않은 입력을 받아 바꾼 값을 뒤에 붙인 스키마로 넘겨 거기서 검증한다.
인자는 둘이다. 앞에는 가공 함수, 뒤에는 그 결과를 검사할 스키마가 온다. 함수가 먼저 돌고, 그 반환값이 스키마의 입력이 된다.
import { z } from 'zod'
const IntFromString = z.preprocess((val) => {
if (typeof val === 'string') return Number.parseInt(val)
return val
}, z.int())
IntFromString.parse('42') // 42 — 먼저 parseInt, 그다음 z.int() 검사
이건 transform 하나를 스키마 앞에 pipe로 이어 붙인 것과 같다. z.preprocess는 그 조합을 짧게 쓰는 이름이다.
작동 원리
순서가 핵심이다. fn이 손대지 않은 입력을 먼저 받는다. 그 반환값이 뒤 스키마로 들어가고, 스키마가 그 값을 검증한다. fn은 검증보다 먼저 돌기 때문에, 아직 타입이 없는 unknown을 그대로 마주한다.
그래서 preprocess 스키마의 입력 타입은 기본이 unknown이다. fn 매개변수에 타입을 직접 달면 그만큼 좁아진다.
const trimmed = z.preprocess(
(val: string | null | undefined) => val?.trim() ?? '',
z.string(),
)
type Input = z.input<typeof trimmed> // string | null | undefined
type Output = z.output<typeof trimmed> // string
입력과 출력 타입이 갈린다. fn이 받는 쪽(z.input)과 스키마가 통과시킨 쪽(z.output)이 서로 다른 값이다. 파싱 전과 후를 두 타입으로 나눠 잡을 수 있다는 뜻이다.
fn의 반환값은 뒤 스키마가 받을 수 있는 입력이어야 한다. 그 반환값이 곧 검증 대상이라, 스키마가 기대하는 모양과 어긋나면 거기서 막힌다.
쓰는 법
함수를 앞, 스키마를 뒤에 둔다. 인자 순서가 곧 실행 순서다. 왼쪽 함수가 먼저 돌고, 오른쪽 스키마가 그 결과를 본다.
입력 타입을 좁히려면 fn 매개변수에 타입을 적는다. 안 적으면 unknown이라 어떤 값이든 들어오고, 적으면 z.input이 거기에 맞춰 좁아진다.
// 빈 문자열을 undefined로 바꿔 optional을 태운다
const OptionalName = z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().min(2).optional(),
)
OptionalName.parse('') // undefined — 빈 문자열은 없는 값이 된다
OptionalName.parse('Yu') // 'Yu'
실전 예제
폼이나 CSV에서 태그가 'react, zod, ts'처럼 콤마로 이어진 문자열 하나로 온다. 이걸 검증된 문자열 배열로 받으려면, 배열로 나누는 일이 검증보다 먼저 일어나야 한다. 나누는 건 preprocess가 맡고, 나뉜 배열은 z.array가 검사한다.
import { z } from 'zod'
const Tags = z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').map((t) => t.trim()) : val),
z.array(z.string().min(1)).max(5),
)
Tags.parse('react, zod, ts') // ['react', 'zod', 'ts']
쓰는 이유
바깥에서 오는 값은 스키마가 바라는 모양과 어긋난 채 도착하는 일이 흔하다. 콤마가 낀 숫자, 빈 문자열, 콤마로 이어 붙인 목록처럼 검증에 앞서 한 번 손봐야 통과할 값들이다. 이때 transform으로 다듬으면 다듬은 결과가 검증을 건너뛴다. preprocess는 가공을 검증보다 앞에 세워, 다듬은 값이 그대로 검사 대상이 되게 한다. z.coerce가 String·Number 같은 정해진 변환만 태우는 것과 달리, preprocess는 fn 자리에 아무 로직이나 넣을 수 있어 손질의 폭이 넓다. 대신 입력이 unknown이라는 점, 그리고 transform과 방향이 반대라는 점만 분명히 잡고 쓰면 된다.
Reference