무엇
z.function은 함수가 아니라 함수를 검사할 스키마를 만드는 함수다. input과 output에 스키마를 걸어 두면, 그 규칙대로 인자와 반환값을 검사하는 진짜 함수는 .implement()로 따로 뽑는다.
z.function({ ... })이 돌려주는 값 자체는 아직 부를 수 없다. 여기에 .implement(fn)을 걸어야 실행 가능한 함수가 나온다.
import { z } from 'zod'
const AddInts = z.function({
input: [z.number().int(), z.number().int()],
output: z.number().int(),
})
const add = AddInts.implement((a, b) => a + b)
add(2, 3) // 5
add(2, '3') // ZodError — 두 번째 인자가 숫자가 아니다
AddInts는 검사 규칙을 담은 스키마고, add처럼 .implement()를 거친 값만 함수로 호출한다.
무슨 일이 일어나나
input은 인자 스키마를 배열로 넘기면 내부에서 튜플로 묶인다. 순서와 개수가 그대로 인자의 순서와 개수가 된다. output은 반환값 스키마 하나다. 둘 다 생략할 수 있는데, input을 생략하면 인자를 검사하지 않고 그대로 받고, output을 생략하면 반환값을 검사하지 않고 그대로 돌려준다.
const Loose = z.function() // input, output 둘 다 생략 — 아무 인자나 받고 아무 값이나 돌려준다
const InputOnly = z.function({ input: [z.string()] }) // 반환값은 검사하지 않는다
z.object와 다른 지점이 하나 있다. z.function이 만든 스키마에 z.infer를 걸면 함수가 아니라 타입 시그니처만 나온다.
type AddIntsType = z.infer<typeof AddInts> // (a: number, b: number) => number
이 타입은 컴파일 타임에만 쓰인다. 호출할 때마다 검사가 걸리는 함수를 손에 쥐려면 .implement()를 거쳐야 하고, z.infer는 그 함수를 만들어 주지 않는다.
.implement()가 돌려준 함수는 불릴 때마다 인자를 input으로 먼저 검사하고, 원래 함수를 실행한 뒤, 반환값을 output으로 다시 검사한다. 어느 쪽이든 어긋나면 ZodError를 던진다. 원래 함수가 비동기라면 implementAsync를 쓴다. 인자와 반환값 검사를 모두 await로 처리하고, 결과를 Promise로 돌려준다.
const AsyncGreet = z.function({
input: [z.string()],
output: z.string(),
})
const greet = AsyncGreet.implementAsync(async (name) => {
return `안녕, ${name}`
})
await greet('영식') // '안녕, 영식'
사용법
인자와 반환값을 스키마로 정하고, .implement()로 실제 함수를 뽑는다. z.function({ input, output })은 규칙만 담은 스키마고, 그 규칙을 만족하는 함수 본문은 implement에 따로 넘긴다.
반환값을 검사할 필요가 없으면 output을 생략한다. 검사를 인자 쪽에만 걸고 싶을 때 쓰는 방식이다.
const Divide = z.function({
input: [z.number(), z.number().refine((n) => n !== 0, '0으로 나눌 수 없다')],
output: z.number(),
})
const divide = Divide.implement((a, b) => a / b)
divide(10, 2) // 5
divide(10, 0) // ZodError — refine이 막는다
함수 본문이 비동기면 처음부터 implementAsync로 뽑는다. implement에 async 함수를 넘기면 반환값이 Promise라 output 스키마가 그 내부를 보지 못하고 어긋난다.
실무 예시
외부에 공개하는 유틸 함수에 인자 검사를 걸어 두면, 잘못된 값이 함수 안으로 들어오는 순간 바로 막힌다. 호출부 어딘가에서 조용히 NaN이나 음수가 흘러 다니는 일이 줄어든다.
import { z } from 'zod'
const CalculatePrice = z.function({
input: [
z.object({
quantity: z.number().int().positive(),
unitPrice: z.number().nonnegative(),
discountRate: z.number().min(0).max(1),
}),
],
output: z.number().nonnegative(),
})
export const calculatePrice = CalculatePrice.implement(({ quantity, unitPrice, discountRate }) => {
return quantity * unitPrice * (1 - discountRate)
})
calculatePrice({ quantity: 3, unitPrice: 1000, discountRate: 0.1 }) // 2700
calculatePrice({ quantity: -1, unitPrice: 1000, discountRate: 0 }) // ZodError — quantity가 양수가 아니다
왜 중요한가
함수는 z.object처럼 한 번 파싱하고 끝나는 대상이 아니라, 호출할 때마다 검사가 걸리는 대상이다. z.function은 그 차이 때문에 다른 스키마와 쓰는 법이 갈린다. 인자와 반환값을 한 번 선언해 두면, 그 뒤로 함수가 몇 번 불리든 매번 같은 규칙으로 검사한다. 모듈 경계에 있는 공개 함수나 여러 곳에서 재사용하는 유틸에 걸어 두면, 잘못된 인자가 함수 안으로 들어오는 지점을 명확히 잡아낸다. z.infer로 타입만 뽑는 것과 .implement()로 검사가 걸린 함수를 뽑는 것을 구분해 두면, z.function을 헷갈리지 않고 쓸 수 있다.
Reference