한 줄 정의
z.exactOptional은 객체 키를 optional로 만들되, "키가 없음"과 "키는 있고 값이 undefined"를 서로 다른 것으로 취급하는 스키마다. 키를 통째로 빼는 것만 허용하고, undefined를 값으로 채워 넣는 건 막는다.
TypeScript의 exactOptionalPropertyTypes 컴파일러 옵션이 타입 레벨에서 하는 구분을, 런타임 검증에서 그대로 재현한다.
import { z } from 'zod'
const User = z.object({
name: z.string().exactOptional(),
})
User.parse({}) // 통과 — 키가 아예 없다
User.parse({ name: 'yoda' }) // 통과
User.parse({ name: undefined }) // ZodError — 키는 있는데 값이 undefined다
작동 원리
일반 .optional()은 입력을 in 체크가 아니라 값 자체로 판단한다. 그래서 키가 없는 것과 키는 있는데 값이 undefined인 것을 같은 것으로 본다 — 둘 다 통과시킨다.
const Loose = z.object({ name: z.string().optional() })
Loose.parse({}) // 통과
Loose.parse({ name: undefined }) // 통과 — optional은 이것도 허용한다
.exactOptional()은 파싱 단계에서 입력 객체에 그 키가 실제로 존재하는지를 먼저 본다. 키 자체가 없으면 검사를 건너뛰고 통과시키지만, 키가 존재하는 순간부터는 값이 무조건 내부 스키마(z.string())를 만족해야 한다 — undefined는 z.string()을 만족하지 않으므로 여기서 막힌다. 즉 "생략 가능"과 "undefined 허용"을 분리해서 검사한다.
이 구분은 TypeScript가 exactOptionalPropertyTypes: true일 때 하는 구분과 정확히 대응한다. 그 옵션 아래에서 { name?: string }은 name 키를 아예 안 주는 건 되지만, { name: undefined }처럼 값으로 undefined를 대입하는 건 타입 에러다. .optional()은 이 옵션이 꺼져 있을 때의 느슨한 타입과 맞고, .exactOptional()은 켜져 있을 때의 엄격한 타입과 맞는다.
쓰는 법
필드 스키마에 메서드로 붙이거나, 함수로 감싼다. 둘은 동치다.
z.string().exactOptional()
z.exactOptional(z.string())
object 안 키 하나하나에 적용하는 용도다. 스키마 전체가 아니라, 그 키가 "생략 가능"임을 표시하는 자리에서 .optional() 대신 골라 쓴다.
const Config = z.object({
host: z.string(),
timeoutMs: z.number().exactOptional(), // 없으면 기본값을 쓰고, undefined를 명시하는 건 실수로 본다
})
exactOptionalPropertyTypes를 켠 프로젝트에서 짝을 맞춘다. 이 옵션이 꺼져 있으면 z.infer가 만든 타입과 실제 TS 타입 검사 사이에 차이가 없어, .optional()로도 충분하다.
실전 예제
PATCH 요청 바디처럼 "이 필드를 건드리지 않는다"와 "이 필드를 명시적으로 지운다"를 구분해야 하는 자리에서 쓴다. 후자는 보통 null로 표현하고, 전자를 .exactOptional()로 막아 undefined가 실수로 섞여 들어오는 걸 파싱 단계에서 잡는다.
import { z } from 'zod'
const PatchProfile = z.object({
displayName: z.string().exactOptional(), // 안 보내면 그대로 둔다
bio: z.string().nullable().exactOptional(), // 지우려면 null, 안 건드리려면 키 자체를 뺀다
})
type PatchProfile = z.infer<typeof PatchProfile>
function applyPatch(current: Profile, patch: PatchProfile) {
// patch에 'displayName' in patch로 존재 여부만 확인하면 되고,
// undefined가 값으로 끼어들 걱정을 안 해도 된다
return { ...current, ...patch }
}
쓰는 이유
exactOptional은 잘 쓰이지 않지만, 쓰이는 자리에서는 정확성이 걸린다. PATCH API, 폼 diff, 설정 병합처럼 "안 건드림"과 "undefined로 덮어씀"이 다른 결과를 낳는 코드에서, 일반 optional은 그 둘을 뭉개 버그를 숨긴다. exactOptionalPropertyTypes를 켠 TypeScript 프로젝트라면 스키마의 엄격도를 타입의 엄격도와 맞춰야 z.infer로 뽑은 타입이 실제로 정직해진다 — 이 메서드가 없으면 파싱은 통과시키는데 타입은 거부하는 어긋남이 생긴다.
Reference