최근 하나의 취미가 생겼습니다! 바로 type-challenges(TypeScript 타입 시스템만으로 문제를 푸는 오픈소스 문제 모음입니다)를 풀며 Typescript를 깊게 이해하는 것입니다. Pick 이나 ReadOnly 같은 내장되어있는 유틸리티 타입 뿐만 아니라, 다양한 유틸리티 타입들을 직접 만들어보는 것인데요? 이를 풀어보며, 문득 매일같이 쓰는 zod 라이브러리가 어떻게 동작하는지 궁금해졌습니다.
TypeScript에서 타입을 쓴다는 것은 일반적으로 값을 가두는 일입니다.
const age: number
위처럼 타입을 선언하면 age가 number 바깥으로 나가지 못하게 울타리를 치는 것이고, 이 때, 방향은 언제나 타입 → 값입니다. 그런데 Zod는 생각해보면 반대로 갑니다. schema라는 값을 먼저 만들면 타입이 거기서 나옵니다. Zod의 일반적인 사용 모습입니다. 한 번의 정의에서 타입과 런타임 검증 함수까지 함께 나옵니다.
import * as z from "zod"; // 1. 값: schema를 정의하고 const User = z.object({ name: z.string(), age: z.number() }); // 2. 타입: 그 값에서 꺼내고 type User = z.infer<typeof User>; // { name: string; age: number } // 3. 런타임: 같은 값이 검증까지 합니다 const user = User.parse(input); // 통과하면 User, 아니면 throw
여기서 두 가지가 궁금해졌습니다.
- 값에서 타입이 나오는 일은 어떻게 가능한가.
- 그리고 타입은 컴파일 타임에만 존재한다고 알고 있는데, 런타임에 들어온 값의 타입이 틀렸다는 것은 어떻게 아는가.
Zod는 TypeScript 생태계에서 가장 널리 쓰이는 runtime 검증 라이브러리입니다. 서버 응답, form 입력, 환경 변수처럼 타입 시스템이 보증해 주지 못하는 바깥의 값이 코드로 들어올 때마다 사용합니다. schema를 한 번 정의하면 검증 로직과 정적 타입을 동시에 얻는다는 것이 핵심 가치입니다.
이 글은 Zod 4 버전을 기준으로, Zod를 직접 흉내내보며 두가지 궁금증을 풀어봅니다. 이 글에서는 string·number·object를 지원하는 뼈대까지 만들고, optional이나 transform 같은 확장까지 직접 만들며 이해한 점. 그리고 부딪힌 한계 지점을 공유합니다.
일단 만들어 봅니다 — 가장 단순한 형태
스키마라는 단어는 잠시 잊고, 가장 원시적인 형태부터 시작합니다. 값이 어떤 타입일지 모르는 상황에 "unknown을 받아 검증하고, 통과하면 구체 타입으로 돌려주는" 일은 함수 하나로 충분합니다.
// 검증은 그냥 함수입니다 function parseString(value: unknown): string { if (typeof value !== "string") throw new Error("string이 아닙니다"); return value; } const name = parseString(JSON.parse(input)); // 통과하면 string, 아니면 throw
한 가지 헷갈리기 쉬운 지점을 여기서 짚고 갑니다.
typeof value !== "string"이 검사하는 타입은 TypeScript의 타입이 아닙니다. JavaScript에도 타입은 있습니다. string, number, boolean, object 같은 값의 종류는 언어 명세에 정의된 런타임에 판단하는 것이고, typeof 연산자가 들어온 값을 판단 후에 이 값들을 돌려줍니다. 즉 이 때는 값과, 값의 비교입니다. TypeScript의 타입은 그 위에 얹힌 컴파일 타임의 주석이라 실행 전에 지워지지만, JavaScript의 타입은 값과 함께 런타임까지 살아남습니다. parseString이 하는 일은 이 둘을 잇는 것입니다. JavaScript의 런타임 타입 검사(typeof)가 통과하면, TypeScript가 그 검사를 읽고 value를 string으로 좁혀 줍니다(narrowing — 제어 흐름을 따라 타입을 좁히는 TypeScript의 기능입니다). 두 타입 세계가 typeof라는 다리에서 만나는 셈입니다.런타임 검증이라는 일 자체에는 라이브러리가 필요 없습니다. 문제는 이런 함수가 타입마다 하나씩 생기고, 서로 조합되어야 할 때 시작됩니다. 객체의 각 필드마다 검증 함수를 짝지어야 하고, 나중에는 그 조합에서 타입도 꺼내야 합니다. 그러려면 이 하나 하나의 함수들을 일관된 모양으로 들고 다닐 그릇이 필요합니다.
한 단 계 추상화 해봅니다. 함수를 객체로 감싸고, 결과 타입에 제네릭으로 이름을 붙입니다.
// 검증 함수를 담는 공통 그릇 — zod 에서 스키마를 흉내냈습니다. interface Schema<Output> { parse(value: unknown): Output; } function string(): Schema<string> { return { parse(value) { if (typeof value !== "string") throw new Error("string이 아닙니다"); return value; }, }; } function number(): Schema<number> { return { parse(value) { if (typeof value !== "number") throw new Error("number가 아닙니다"); return value; }, }; }
string과 number의 구현이 사실상 복사/붙여넣기라는 점이 눈에 거슬립니다.한 단계 더 추상화해 봅니다. 머릿속에 먼저 떠오르는 모양은 아마 이것일 텐데, 아쉽게도 동작하지 않습니다.
// 동작하지 않는 코드입니다 — 제네릭 T는 런타임에 존재하지 않습니다 function primitive<T>(): Schema<T> { return { parse(value) { if (typeof value !== typeof T) throw new Error("T가 아닙니다"); // ^ 'T' only refers to a type return value; }, }; }
TypeScript의 제네릭은 컴파일이 끝나면 지워집니다(type erasure — 타입 정보가 JavaScript 출력물에 남지 않는 성질입니다). 즉, 앞서 언급했듯이 런타임에
T라는 값은 없으므로 typeof T를 비교할 수 없습니다. "타입은 컴파일 타임에만 존재한다"는 제약에 따라서, 런타임의 검사는 언제나 값으로만 가능합니다. typeof 연산도, "string"이라는 문자열도, 검사를 수행하는 parse 함수 자체도 전부 값의 세계에 속합니다. 타입 시스템이 하는 일은 "parse를 통과해 돌아온 값은 Output이다"라는 약속을 하는 것입니다.그래서 추상화의 방향도 뒤집어야 합니다. 타입에서 런타임 검사를 끌어내는 것이 아니라, 런타임 값인
"string"이라는 문자열에서 타입을 끌어옵니다.// typeof 결과 문자열을 키로, 대응하는 타입을 값으로 갖는 조회 테이블입니다 type PrimitiveMap = { string: string; number: number; boolean: boolean; }; function primitive<K extends keyof PrimitiveMap>(type: K): Schema<PrimitiveMap[K]> { // ^^^ 여기서 string | number | boolean 으로 K 가 제약 return { parse(value) { if (typeof value !== type) throw new Error(`${type}이 아닙니다`); return value as PrimitiveMap[K]; }, }; } const string = () => primitive("string"); const number = () => primitive("number");
primitive("string")을 부르면 리터럴 "string"에서 K = "string"이 추론되고, 인덱스 접근 PrimitiveMap[K]가 타입 string을 돌려줍니다. 이 또한 값("string")이 먼저 있고, 타입이 그 값에서 흘러나옵니다. 우리의 primitive("string")에 해당하는 것이 z.string()입니다.Zod가 다루는 스키마 타입의 스펙트럼은 훨씬 넓습니다. 실제 Zod에서 스키마를 선언하는 모습은 이렇습니다.
import * as z from "zod"; const User = z.object({ name: z.string(), age: z.number().optional(), tags: z.array(z.string()), }); type User = z.infer<typeof User>; // { name: string; age?: number; tags: string[] }
여기 등장하는
z.string(), z.object(), z.array(), .optional() 하나하나가 전부 독립된 스키마 타입이고, 그 전체 목록이 소스에 합집합으로 선언돼 있습니다.Zod 4 소스(
schemas.ts, 4.4.3 기준)에 선언된 전체 스키마 타입의 합집합입니다.export type $ZodTypes = | $ZodString | $ZodNumber | $ZodBigInt | $ZodBoolean | $ZodDate | $ZodSymbol | $ZodUndefined | $ZodNullable | $ZodNull | $ZodAny | $ZodUnknown | $ZodNever | $ZodVoid | $ZodArray | $ZodObject | $ZodUnion | $ZodIntersection | $ZodTuple | $ZodRecord | $ZodMap | $ZodSet | $ZodLiteral | $ZodEnum | $ZodFunction | $ZodPromise | $ZodLazy | $ZodOptional | $ZodDefault | $ZodPrefault | $ZodTemplateLiteral | $ZodCustom | $ZodTransform | $ZodNonOptional | $ZodReadonly | $ZodNaN | $ZodPipe | $ZodSuccess | $ZodCatch | $ZodFile;
$ZodString 같은 원시 타입뿐 아니라, 우리가 곧 흉내낼 $ZodObject, 그리고 다음 글의 주제가 될 $ZodOptional과 $ZodTransform까지 전부 독립된 스키마 타입으로 선언돼 있습니다. 스키마에서 타입 꺼내기 — 조건부 타입과 infer 키워드
스키마는 만들었으니, 이제 스키마에서 타입을 꺼내는
Infer가 필요합니다. 여기서 TypeScript의 조건부 타입과 infer 키워드가 등장합니다. 공식 핸드북은 조건부 타입을 JavaScript의 삼항 연산자에 빗대 설명합니다. SomeType extends OtherType ? TrueType : FalseType
다음과 같은 형태로,
SomeType 이 OtherType 에 해당하면 TrueType이 되며, 해당하지 않으면 FalseType이 됩니다. infer 키워드는 이 extends 절 안에서만 쓸 수 있습니다.// "S가 어떤 타입 T의 Schema라면, 그 T를 돌려달라" type Infer<S> = S extends Schema<infer T> ? T : never; const Name = string(); type Name = Infer<typeof Name>; // string
여기서 잠시 용어를 분리해 둡니다. 이 글에는 같은 단어 infer가 두 층위로 등장합니다. 방금 쓴 TypeScript 문법인
infer 키워드와, Zod가 제공하는 유틸리티 타입 z.infer입니다. 둘은 비슷한 역할을 하기에, 이름이 같은 것은 우연이 아니지만 둘은 다른 것이므로, 이후 본문에서는 항상 infer 키워드 / z.infer로 구분해 적습니다.다만 실제로 Zod를 primitive 하나만으로 쓰는 일은 드뭅니다. 일반적으로는 key와 value(스키마)가 쌍을 이루는 object 형태로 사용하므로, 같은 원리가 이제 더 다양한 모양으로 반복됩니다.
object — 값에서 타입추론하기
목표는 이것입니다.
const User = m.object({ name: m.string(), age: m.number() }); type User = Infer<typeof User>; // { name: string; age: number }
인자로 넘긴 객체 리터럴의 모양이 타입에 담겨야 합니다. 이를 가능하게 하는 것이 TypeScript의 호출 지점(call site) 추론입니다. 제네릭 함수를 호출할 때 타입 인자를 명시하지 않으면, TypeScript는 인자의 값으로부터 타입 파라미터를 추론합니다.
// 타입 인자를 적지 않아도 인자 값에서 T가 추론됩니다 declare function identity<T>(value: T): T; const a = identity("hello"); // T = "hello"
이 추론은
identity 같은 예제용 함수를 직접 구현해야하는 것이 아니라, 제네릭이 선언된 함수라면 호출할 때마다 항상 작동하는 언어의 기본 동작이고, 우리가 매일 쓰는 함수들에서 이미 일어나고 있습니다.// 둘 다 타입 인자를 적은 적이 없지만 추론은 늘 작동하고 있었습니다 const strs = [1, 2, 3].map((n) => n.toString()); // map<U>의 U = string → string[] const [count, setCount] = useState(0); // useState<S>의 S = number
Zod에서는 이를 활용해,
object의 제네릭을 shape 자체로 잡으면, { name: m.string(), age: m.number() }를 넘기는 순간 S가 { name: Schema<string>; age: Schema<number> }로 캡처됩니다. 캡처한 shape를 결과 객체 타입으로 바꾸는 것은 매핑 타입의 몫입니다.// key와 스키마의 쌍 — Zod가 shape라고 부르는 그 형태입니다 type Shape = Record<string, Schema<unknown>>; // shape의 각 키에서 Infer를 돌려 객체 타입을 재구성합니다 type ObjectOutput<S extends Shape> = { [K in keyof S]: Infer<S[K]>; }; function object<S extends Shape>( shape: S, ): Schema<ObjectOutput<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 };
여기까지 약 50줄로 Zod를 흉내낼 수 있습니다. 중첩 객체도
Infer가 재귀적으로 풀어냅니다. 즉, 호출 지점 추론이 값의 모양을 제네릭으로 캡처하고, 매핑 타입이 그 제네릭을 결과 타입으로 변환합니다.z.object(shape)에서 일어나는 일도 규모만 다를 뿐 같습니다.흉내내기의 한계 — optional은 어떻게 할 건데?
다만 우리가 흉내낸 Zod는 뼈대일 뿐이라, 실전 Zod의 기능을 흉내 내려는 순간 바로 문제가 발생합니다. 맛보기로 옵셔널 필드를 시도해 보겠습니다.
// 순진한 구현 — 타입에 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를 생략하면 에러가 납니다. 진정한 optional이라면, key 자체가 없을 수도 있어야하는데, 둘은 완전히 다른 상황입니다. 즉, { age: number | undefined }와 { age?: number }는 다른 타입입니다. 그리고 .transform()처럼 들어가는 타입과 나오는 타입이 다른 스키마는, 지금의 Schema<Output>으로는 표현할 자리조차 없습니다. 이러한 문제는 어떻게 해결해야할까요? 마무리
타입이 런타임에 없기 때문에 검증은 값(
parse)이 하고, 검증이 값에 있기 때문에 타입은 값에서 나와야 합니다. 그 "나오는" 메커니즘이 호출 지점 추론(값의 모양 → 제네릭)과 매핑 타입(제네릭 → 결과 타입)이었습니다.다음 글에서는 방금 마주한 한계들을 해결하기 위해 어떠한 선택들을 했는지 알아봅니다. 정말 어렵습니다.. optional 키에 진짜
?를 붙이는 key remapping, transform을 위해 input 타입을 실어 나를 자리를 만드는 phantom 타입, 그리고 그 해법들이 실제 Zod 4 소스(z.infer의 정의, $InferObjectOutput)와 어떻게 일치하는지를 대조합니다.
kyu-log