1편에서는 직접 Zod를 흉내내보며 왜 Zod 스키마가 타입 → 값이 아닌, 값 → 타입의 순서를 따를 수밖에 없었는가에 대해서 알아봤고, 이를 직접 구현해보면서 얻은 인사이트들을 정리해봤습니다. 그 과정에서 두 가지 한계에 부딪혔습니다.
optional을 1편의 구조대로 이어서 구현하자니 age?: number가 아니라 age: number | undefined가 나왔고, age를 생략하면 에러가 났습니다. 다른 하나는 transform입니다. 들어가는 타입과 나오는 타입이 다른 schema는, 타입을 하나만 들고 다니는 Schema<Output>으로는 표현할 자리조차 없었습니다.이 글은 그 두 한계를 어떻게 극복하는지 다룹니다. 그 과정에서 키를 optional로 만드는 key remapping과, input 타입을 실어 나를 빈 자리를 만드는 phantom 타입에 대해서 배웁니다. 그리고 실제 Zod 4 소스(4.4.3 기준)와 어떤 차이가 있는지 비교해봅니다.
직접 만든 Zod 구조의 한계
// 1편의 순진한 optional — 타입에 undefined만 더했습니다 function optional<S extends Schema<unknown>>(inner: S): Schema<Infer<S> | undefined> { return { parse(value) { if (value === undefined) return undefined; return inner.parse(value); }, }; } const User = m.object({ name: m.string(), age: optional(m.number()) }); type User = Infer<typeof User>; // { name: string; age: number | undefined } // ^^^ age?: 가 아니라 age: 입니다 const u: User = { name: "kim" }; // 에러: Property 'age' is missing
값 타입에
undefined가 더해졌을 뿐 키는 여전히 필수라, age를 빼면 에러가 납니다. { age: number | undefined }와 { age?: number }는 다른 타입입니다. 전자는 "키는 반드시 있고 값이 undefined일 수 있다", 후자는 "키 자체가 없어도 된다"입니다.두 번째 한계는 더 근본적입니다. 바로 input과 output의 타입이 달라지는 경우입니다. 그 전에, 실제 Zod에서
transform이 어떤 역할을 하는지 먼저 봅니다.import * as z from "zod"; // 문자열을 받아 검증한 뒤, 그 길이(number)로 바꿔 내보냅니다 const NameLength = z.string().transform((s) => s.trim().length); NameLength.parse(" kim "); // 3 (number) type In = z.input<typeof NameLength>; // string — parse에 들어가는 타입 type Out = z.output<typeof NameLength>; // number — parse를 통과해 나온 타입
.transform()은 검증을 통과한 값을 다른 형태로 바꿔 내보내는 기능입니다. 위 schema는 string을 받아 number를 돌려주므로, 들어가는 타입과 나오는 타입이 서로 다릅니다. 그래서 Zod는 둘을 z.input과 z.output으로 따로 꺼낼 수 있게 합니다.간단한 형태인
m.string()처럼 둘이 같은 schema는 input과 output을 구분할 일이 없지만, transform은 input과 output을 일부러 다르게 만듭니다.이제 1편의
Schema<Output>을 다시 봅니다.// 1편에서 만든 Schema 타입 — Output 이라는 타입을 하나만 들고 다닙니다 interface Schema<Output> { parse(value: unknown): Output; }
Output은 parse의 반환 타입을 가리키는 제네릭으로 선언되어 있습니다. 반면 input 타입은 적을 곳이 없습니다. parse의 인자가 어떤 타입이 들어올지 모르는 unknown이기 때문입니다. input과 output이 같은 보통의 schema에서는 상관 없지만, input과 output이 달라지는 transform을 표현하려는 순간 막힙니다. 이를 확장해야만 합니다. 다음은 두번째 문제입니다.optional 키에 진짜 ? 붙이기 — key remapping
const User = m.object({ name: m.string(), age: optional(m.number()) }); type User = Infer<typeof User>; // { name: string; age?: number }
문제는 1편의
ObjectOutput이 모든 키를 똑같은 방식으로 매핑하다 보니, 특정 키에만 ?를 붙일 길이 없었습니다. 필요한 것은 두 가지입니다. 첫째, "이 schema는 optional이다"라는 사실을 타입이 알아야 합니다. 둘째, 그 표식을 읽어 해당 키만 ?와 같이 optional로 표현할 수 있어야 합니다.결론부터 말하자면,
Schema에 optional 여부를 나타내는 칸을 하나 두고, optional()이 만드는 schema에서만 그 칸을 정확한 값으로 좁혀 단언합니다.// optional 여부를 타입 레벨에 새기는 표식을 추가합니다 interface Schema<Output> { parse(value: unknown): Output; optional?: "optional"; // 보통 schema는 이 칸이 없을 수 있습니다 } // optional schema는 항상 "optional"을 갖습니다 interface OptionalSchema<Output> extends Schema<Output> { optional: "optional"; }
이 두 interface의 차이는
optional이라는 값 자체가 옵셔널이냐 아니냐의 차이입니다. 그냥 schema는 optional?: "optional"이라 이 칸이 없을 수 있고, OptionalSchema만 optional: "optional"로 칸을 반드시 갖습니다. 이 차이를 활용해 key remapping을 적용합니다.key remapping이란? 매핑 타입에서[K in keyof T as ...]형태로 각 키를 다른 키 이름으로 바꾸거나never로 걸러내는 기능입니다.
표현이 어려워서 그렇지, 단순히
as를 활용한 타입 단언입니다. 이를 활용하여 키마다 조건을 확인해 어떤 키는 살리고 어떤 키는 결과에서 지울 수 있습니다. 이때 as의 결과가 never가 된 키는 매핑 결과에 등장하지 않습니다.물론, 여기서
as를 쓰는 것에 경각심을 가져야 합니다. 타입 단언을 남발하게 되면 TypeScript를 쓰는 이유 자체가 없어지기 때문입니다. 그러나 매핑 타입에서 키마다 자유롭게 바꿀 수 있는 것은 값 타입뿐입니다. [K in keyof S]: ...의 : 뒤는 키별로 다르게 줄 수 있지만, 키 자체를 optional로 만드는 경우에는 그렇지 않습니다. [K in keyof S]?:는 모든 키를 optional로 만들고 -?:는 모든 키에서 ?를 떼어 낼 뿐입니다.// 값을 never로 둬도 키는 사라지지 않습니다 — 여전히 필수로 남습니다 type T = { [K in "a" | "b"]: K extends "a" ? string : never }; // { a: string; b: never } 결과로 나온 타입을 보면 b 키가 그대로 있고, 심지어 필수입니다.
그래서 "이 키만 빼겠다", "이 키만 optional로 만들겠다"를 표현하려면 손댈 수 있는 곳이
as를 활용한 타입 단언뿐입니다. 필수 키와 optional 키를 한 블록에서 섞을 수 없으니, ?가 걸린 블록과 걸리지 않은 블록으로 나눈 뒤, 각 키를 as로 알맞은 블록에만 남기고 나머지는 never로 떨궈 내보내는 것입니다.필수 키 블록과 optional 키 블록을 따로 만들어 교집합(
A & B)으로 합칠 텐데, 교집합은 타입 힌트에 A & B 모양 그대로 노출되어 읽기 불편합니다. 이를 평범한 객체 한 덩어리로 펼쳐 보여 주는 흔한 유틸리티 타입 Prettify를 먼저 만듭니다.// 교집합(A & B) 객체를 펼쳐 한 덩어리로 줍니다. type Prettify<T> = { [K in keyof T]: T[K] } & {};
예를 들어 아래의 두 객체 타입을
&로 합치면, 합쳐진 결과가 A & B라는 형태 그대로 남습니다. 의미상으로는 두 객체가 하나로 합쳐진 것이지만, 에디터에 마우스를 올려 타입을 확인하면 합쳐진 한 덩어리가 아니라 교집합 식이 그대로 보입니다. 이는 의미상 같은 표현이지만 보기 불편하기에 하나로 합칩니다.type A = { name: string }; type B = { age?: number }; type Merged = A & B; // 에디터 힌트: A & B ← 합쳐진 모양이 아니라 식 그대로 보입니다. // {name: string} & {age?: number} type Pretty = Prettify<A & B>; // 에디터 힌트: { name: string; age?: number } ← 한 객체로 펼쳐집니다
키와 값은 그대로 옮겨 적으니 의미는 전혀 바뀌지 않고, 표시만
A & B에서 { name: string; age?: number }라는 평범한 객체 모양으로 정리됩니다.// 표식을 가진 키와 아닌 키를 두 블록으로 갈라 합칩니다 type Shape = Record<string, Schema<unknown>>; type OptionalMarker = { optional: "optional" }; type ObjectOutput<S extends Shape> = Prettify< // 1) 표식 없는 키는 그대로 필수 { [K in keyof S as S[K] extends OptionalMarker ? never : K]: Infer<S[K]> } & // 2) 표식 있는 키는 ?를 붙여 optional { [K in keyof S as S[K] extends OptionalMarker ? K : never]?: Infer<S[K]> } >;
첫 블록은 key remapping을 통해 optional 마크를 가진 키를
never로 떨궈 필수 키만 남깁니다. 둘째 블록은 반대로 표식 없는 키를 never로 떨군 뒤, 살아남은 키에 ?를 붙입니다. 두 블록을 교집합으로 합치고 Prettify로 펼치면, 필수 키와 optional 키가 정확히 갈린 객체 타입이 나옵니다.optional도 표식을 달아 다시 구현합니다.// 표식을 달고, output에는 undefined를 더합니다 function optional<S extends Schema<unknown>>( inner: S, ): OptionalSchema<Infer<S> | undefined> { return { optional: "optional", parse(value) { if (value === undefined) return undefined; return inner.parse(value) as Infer<S>; }, }; } const User = m.object({ name: m.string(), age: optional(m.number()) }); type User = Infer<typeof User>; // { name: string; age?: number | undefined } const u: User = { name: "kim" }; // 통과합니다
이제
age를 생략해도 통과합니다. 표식이 타입에 박혀 있어야 매핑 타입이 그것을 읽고 추론합니다. 1편에서 타입이 값에서 나왔듯, 여기서도 optional이라는 성질을 값(schema 객체의 optional 칸)에 먼저 새기고, 타입이 그 값을 읽어 키의 모양을 바꿉니다.조금 복잡하긴 했으나,
as와 Prettify를 활용해 optional한 key를 만들어냈습니다.input 타입을 실어 나를 자리 만들기 — phantom 타입
두 번째 한계를 극복하러 갑니다. output은
parse의 반환 타입에 적혀 있었지만, input은 Schema<Output> 어디에도 적을 곳이 없습니다. transform을 표현하려면 Schema에 input 타입을 담을 프로퍼티를 하나 더 추가해야 합니다. 그렇지만 input에 특정 타입을 지정해 두자니, unknown을 쓰지 않고서는 여러 타입을 받을 수 없습니다.여기서 phantom 타입(런타임에는 아무 값도 들어가지 않고, 타입 정보만 담아 두는 빈 자리입니다)을 씁니다.
Schema에 input을 운반할 칸을 하나 더 두되, 그 칸은 런타임에 늘 비어 있고 타입만 나릅니다. 이 말이 정말 어렵습니다만, 앞선 문제를 해결하기 위해서는 넘어갈 수 없습니다.// Input을 실어 나르는 phantom 슬롯을 추가합니다 (런타임 값은 없습니다) interface Schema<Output, Input = Output> { parse(value: unknown): Output; readonly _input?: Input; // 타입만 운반하고, 실제 값은 들어가지 않습니다 optional?: "optional"; }
Input의 기본값을 Output으로 두었으므로, input과 output이 같은 보통의 schema는 한 타입만 적으면 됩니다. _input이 readonly이고 물음표가 달린 이유는, 이 칸에 실제 값을 넣을 일이 없기 때문입니다. 객체 리터럴 { parse }만 반환해도 타입은 통과합니다.여기서 한 가지를 짚고 갑니다.
Input을 제네릭 파라미터로 선언만 하고 어떤 멤버에서도 쓰지 않으면, 나중에 S extends Schema<infer O, infer I>로 I를 되찾을 수 없습니다. 타입이 어떤 프로퍼티에도 등장하지 않으면 추론이 붙들 곳이 없기 때문입니다. _input이라는 프로퍼티가 실제로 있어야 input 타입이 복원됩니다. 런타임 값 없이 타입만 담아 두는 이 프로퍼티가 phantom 타입의 존재 이유입니다.// output은 첫 제네릭에서, input은 둘째 제네릭에서 꺼냅니다 type Infer<S> = S extends Schema<infer O, any> ? O : never; type InferInput<S> = S extends Schema<any, infer I> ? I : never;
output은 변환 함수가 만들어 내는 새 타입이고, input은 원래 schema의 input을 그대로 물려받습니다.
// output은 fn의 반환 타입, input은 원래 schema의 input을 잇습니다 function transform<S extends Schema<unknown>, NewOut>( inner: S, fn: (value: Infer<S>) => NewOut, ): Schema<NewOut, InferInput<S>> { return { parse(value) { return fn(inner.parse(value) as Infer<S>); }, }; } const Id = transform(m.string(), (s) => s.length); type IdOut = Infer<typeof Id>; // number type IdIn = InferInput<typeof Id>; // string
m.string()의 output(string)이 fn의 입력이 되고, fn의 반환(number)이 새 output이 됩니다. input은 변환 이전, 즉 string 그대로입니다. 이렇게 input과 output이 한 schema 안에서 갈라집니다.object도 두 방향을 갖게 됩니다. output용 매핑과 input용 매핑을 각각 두면 됩니다.
// output 객체와 input 객체를 따로 재구성합니다 type ObjectOutput<S extends Shape> = Prettify< { [K in keyof S as S[K] extends OptionalMarker ? never : K]: Infer<S[K]> } & { [K in keyof S as S[K] extends OptionalMarker ? K : never]?: Infer<S[K]> } >; type ObjectInput<S extends Shape> = Prettify< { [K in keyof S as S[K] extends OptionalMarker ? never : K]: InferInput<S[K]> } & { [K in keyof S as S[K] extends OptionalMarker ? K : never]?: InferInput<S[K]> } >; function object<S extends Shape>(shape: S): Schema<ObjectOutput<S>, ObjectInput<S>> { return { parse(value) { if (typeof value !== "object" || value === null) throw new Error("object가 아닙니다"); const result: Record<string, unknown> = {}; for (const key in shape) result[key] = shape[key].parse((value as Record<string, unknown>)[key]); return result as ObjectOutput<S>; }, }; } export const m = { string, number, object, optional, transform };
ObjectOutput은 각 필드의 output을, ObjectInput은 input을 재구성합니다. 두 매핑 모두 key remapping으로 optional 키를 가르는 부분은 똑같습니다.실제 Zod 4 소스와 대조
이제 Zod 4와 대조해 볼까요? Zod 4 core의
z.infer 정의를 다시 보면 다음과 같습니다.export type input<T> = T extends { _zod: { input: any } } ? T["_zod"]["input"] : unknown; export type output<T> = T extends { _zod: { output: any } } ? T["_zod"]["output"] : unknown; export type { output as infer };
z.infer는 사실 output의 다른 이름이고, output<T>는 T["_zod"]["output"]을 꺼낼 뿐입니다. 우리의 Infer<S>가 첫 제네릭에서 output을 꺼낸 것과 같은 일입니다. 그리고 z.input은 T["_zod"]["input"]을 꺼냅니다. 우리의 InferInput에 해당합니다.그
output과 input, 곧 T["_zod"]["output"]처럼 대괄호로 꺼내던 칸이 우리가 만든 _input 슬롯에 대응합니다. Zod는 모든 schema의 내부를 _zod 한 곳에 모아 둡니다.export interface $ZodTypeInternals<out O = unknown, out I = unknown> extends _$ZodTypeInternals { /** @internal The inferred output type */ output: O; /** @internal The inferred input type */ input: I; }
output: O와 input: I는 주석에 적혀 있듯이 추론용 타입 칸이고, 앞서 말한 대로 런타임에 실제 값이 채워지지 않는 phantom입니다. 우리가 _input 하나만 둔 자리를, Zod는 output과 input 두 칸으로 두고 있습니다.optional도 그대로 있습니다. Zod 내부에는
optin과 optout이라는 칸이 있습니다. 약어를 풀면 opt는 optional, in은 input 방향, out은 output 방향입니다. 즉 optin은 "이 schema가 객체 안에서 input 쪽 키를 optional로 만드는가", optout은 "output 쪽 키를 optional로 만드는가"입니다. 우리의 optional: "optional" 표식을, Zod는 두 방향으로 쪼개 들고 있는 셈입니다.$ZodOptional의 내부를 보면 둘 다 값을 갖고 있습니다.export interface $ZodOptionalInternals<T extends SomeType = $ZodType> extends $ZodTypeInternals<core.output<T> | undefined, core.input<T> | undefined> { optin: "optional"; optout: "optional"; }
output과 input 모두에
| undefined를 더하고, optin과 optout을 "optional"로 표시합니다. 우리가 optional()에서 output에 undefined를 더하고 표식을 단 것과 같습니다.이 표식을 읽어 키에 optional 여부를 붙이는 곳이
$InferObjectOutput입니다. 핵심만 옮기면 이렇습니다.type OptionalOutSchema = { _zod: { optout: "optional" } }; // ... { -readonly [k in keyof T as T[k] extends OptionalOutSchema ? never : k]: T[k]["_zod"]["output"]; } & { -readonly [k in keyof T as T[k] extends OptionalOutSchema ? k : never]?: T[k]["_zod"]["output"]; } & Extra
OptionalOutSchema는 optout이 정확히 "optional"인 schema를 가려내는 타입입니다. 우리의 OptionalMarker와 같은 역할입니다. 그 아래 두 블록은 as를 활용해 한쪽은 필수로, 한쪽은 ?로 만듭니다. 우리의 ObjectOutput과 글자 단위로 같은 구조입니다. -readonly는 readonly를 떼어 내는 표시이고, & Extra는 catchall로 들어온 추가 키를 합치는 자리입니다.마지막으로
transform입니다. Zod 4에서 .transform()은 새 schema를 직접 만드는 대신, 앞 schema와 변환을 잇는 pipe를 만듭니다.transform(tx) { return pipe(this, transform(tx)); }
그 pipe인
$ZodPipe<A, B>의 내부는 두 방향을 각각 다른 쪽에서 가져옵니다.export interface $ZodPipeInternals<A extends SomeType, B extends SomeType> extends $ZodTypeInternals<core.output<B>, core.input<A>> { optin: A["_zod"]["optin"]; optout: B["_zod"]["optout"]; }
output은 뒤 schema
B(변환)에서, input은 앞 schema A(원본)에서 가져옵니다. 우리는 pipe까지 만들지는 않았지만, 우리의 transform이 output을 NewOut으로, input을 InferInput<S>로 둔 것과 정확히 같은 분배입니다. 한 schema가 input과 output을 따로 들고 다닐 수 있는 이유가, 결국 이 두 칸을 양쪽에서 채우기 때문이었습니다.그래도 남는 것
우리가 흉내낸 zod는 여전히 많이 부족합니다. 실제 Zod는 우리가 다루지 못한 상황들까지 처리합니다.
키 optional과 값 optional의 구분이 대표적입니다. 키 자체가 없어도 되는 경우(
{ name?: string })와, 키는 반드시 있되 값이 undefined일 수 있는 경우({ name: string | undefined })는 다른 타입입니다. 우리 가 흉내낸 zod의 optional은 키를 없앨 수 있는 전자 한 종류만 만듭니다. Zod 4는 두 종류를 모두 구분해 표현하지만(Zod 3은 둘을 같은 타입으로 뭉갰습니다), 우리가 만든 것은 그렇지 않습니다.default의 비대칭또한 마찬가지 입니다. 기본값이 있는 필드는 z.input에서는 생략 가능하지만 z.output에서는 항상 채워져 있습니다. input과 output을 두 칸으로 나눠 들고 있기에 가능한 표현입니다. 양방향 변환도 마찬가지입니다. $ZodPipe는 parse의 정방향뿐 아니라 역방향(decode/encode)까지 다루며, $ZodTransform은 역방향에서 에러를 던지는 분기를 따로 갖고 있습니다.이 한계들은 거꾸로, 왜 Zod가 단순한
Schema<Output> 하나로 끝나지 않고 _zod 안에 output, input, optin, optout을 전부 들고 다니는지를 설명합니다. 마무리
주마다 1억 번 넘게 다운로드되는 라이브러리도, 결국은 "값에서 타입을 어떻게 끌어내고, 런타임에 어떻게 막아내고, transform 을 통해 스키마를 어떻게 바꿔 나가는가" 와 같은 질문들이 모여, 지금의 코어한 코드들을 만들어냈습니다. 매일 쓰면서도 한 번도 열어 보지 않았던 것들이었으나, 결국 본질을 따라가보니 이해할 수 있었습니다. 결국은 직접 만들어본 뒤에야, zod 를 조금이나마 이해할 수 있었습니다.
kyu-log