무엇
ZodError는 파싱이 막혔을 때 나오는 값이다. .parse는 이 값을 throw하고, .safeParse는 실패 결과의 error 자리에 담아 돌려준다. 실제 내용은 issues라는 배열에 있고, 항목마다 code, path, message가 들어 있다.
import { z } from 'zod'
const Signup = z.object({
username: z.string(),
age: z.number(),
})
const r = Signup.safeParse({ username: 42, age: '10' })
if (!r.success) {
r.error.issues
// [
// { code: 'invalid_type', path: ['username'], message: 'Invalid input: expected string, received number' },
// { code: 'invalid_type', path: ['age'], message: 'Invalid input: expected number, received string' },
// ]
}
code는 왜 막혔는지, path는 어느 필드인지, message는 사람이 읽을 문장이다. 이 셋만 있으면 실패 하나를 그대로 설명할 수 있다.
무슨 일이 일어나나
issues는 평평한 배열이다. 중첩된 객체를 검사해도 결과는 한 층으로 펼쳐지고, 어느 필드인지는 path 배열이 대신 말해 준다. 필드 하나씩 그려야 하는 폼 같은 자리에서는 이 배열을 매번 순회해 path를 맞춰 보는 일이 번거롭다. 그래서 zod는 issues를 다른 모양으로 바꿔주는 함수 세트를 최상위에 따로 둔다.
z.treeifyError(err)는 issues를 path를 따라 중첩된 트리로 바꾼다. 객체 필드는 properties, 배열 원소는 items 아래로 들어가고, 각 노드에 그 자리의 errors 배열이 붙는다. 필드 이름으로 바로 파고들 수 있어 폼에 맞다.
const tree = z.treeifyError(r.error)
tree.properties?.username?.errors
// ['Invalid input: expected string, received number']
z.flattenError(err)는 한 단계짜리 객체를 위한 더 짧은 모양이다. 최상위 실패는 formErrors에, 필드별 실패는 fieldErrors에 담는다. 중첩이 없는 스키마라면 treeifyError보다 다루기 쉽다.
const flat = z.flattenError(r.error)
flat.fieldErrors.username
// ['Invalid input: expected string, received number']
z.prettifyError(err)는 사람이 그냥 읽으면 되는 여러 줄짜리 문자열을 만든다. 로그나 CLI 출력처럼 필드별로 나눌 필요 없이 실패를 통째로 보여 줄 자리에 맞다.
z.prettifyError(r.error)
// ✖ Invalid input: expected string, received number
// → at username
// ✖ Invalid input: expected number, received string
// → at age
메시지 자체를 바꾸고 싶으면 스키마나 체크에 error를 준다. 문자열을 바로 넘겨도 되고, 함수를 넘기면 실패한 issue를 받아 동적으로 메시지를 만들 수 있다.
z.string({ error: '문자열이어야 합니다' })
z.string().min(8, { error: '8자 이상이어야 합니다' })
사용법
폼처럼 필드 단위로 그려야 하면 treeifyError나 flattenError를 쓴다. 중첩 객체나 배열이 있으면 treeifyError가 구조를 그대로 따라가고, 한 단계짜리 객체면 flattenError의 fieldErrors가 더 짧다.
로그나 CLI처럼 사람이 그냥 읽으면 되는 자리는 prettifyError를 쓴다. 필드별로 나눠 처리할 일이 없으면 issues를 직접 순회할 이유가 없다.
필드 하나의 메시지를 바꾸고 싶으면 그 자리에 error를 준다. 타입 자체가 틀리면 스키마에 준 error가, 체크가 막으면 체크에 준 error가 쓰인다.
z.string({ error: '문자열이 필요합니다' }).min(8, { error: '8자 이상이어야 합니다' })
// 8자 미만인 문자열이 들어오면 뒤쪽 error가 이긴다
실무 예시
회원가입 폼이다. 이메일과 비밀번호를 검사하고, 실패하면 필드별 메시지를 화면에 뿌려야 한다. 한 단계짜리 객체라 flattenError로 issues를 바로 fieldErrors로 바꾼다.
import { z } from 'zod'
const SignupForm = z.object({
email: z.email({ error: '올바른 이메일 형식이 아닙니다' }),
password: z.string().min(8, { error: '비밀번호는 8자 이상이어야 합니다' }),
})
function submitSignup(raw: unknown) {
const r = SignupForm.safeParse(raw)
if (!r.success) {
const { fieldErrors } = z.flattenError(r.error)
return { ok: false, fieldErrors } // { email: [...], password: [...] }
}
return { ok: true, data: r.data }
}
왜 중요한가
parse든 safeParse든 실패의 실제 내용은 결국 ZodError 하나로 모인다. issues 배열 자체는 어떤 상황에도 쓸 수 있게 평평하게 설계돼 있지만, 그 평평함이 폼이나 로그 같은 실제 화면에는 그대로 맞지 않는다. treeifyError, flattenError, prettifyError는 같은 issues를 상황에 맞는 모양으로 바꿔 주는 도구고, error 파라미터는 그 실패의 문장 자체를 도메인 언어로 바꿔 준다. 이 셋을 구분해 두면 검증 실패를 사용자에게 보여 주는 코드가 매번 issues를 손으로 순회하지 않고도 일관되게 나온다.
Reference