TypeScript에서 검증 대신 파싱을 선택해야 하는 이유와, 브랜디드 타입·판별 유니언·엄격한 경계로 신뢰할 수 있는 도메인 타입을 만드는 방법을 살펴본다.
업데이트: 이 글이 마음에 들었다면, 후속 글인 Effect Without Effect-TS: Algebraic Thinking in Plain TypeScript도 읽어보세요. 이 글이 멈춘 지점에서 이어서 아이디어를 더 밀고 나갑니다.
최근에 Alexis King의 Parse, don’t validate를 다시 떠올리고 있었다. 사실 이런 일은 꽤 자주 있다. 대개 if (user.email) 검사들이 따개비처럼 조용히 쌓여 있는 TypeScript 코드베이스를 한참 들여다본 뒤다. 그 글은 2019년 글이고, 그 조언, 아니 원칙은 그보다도 훨씬 오래되었다. 그런데도 내가 읽는 대부분의 TypeScript는 — 민망하지만 내가 쓴 코드도 많이 포함해서 — 여전히 파싱 대신 검증을 한다.
아직 그 글을 읽지 않았다면 요지는 이렇다. 읽는 편이 좋다. 검증기는 “이건 괜찮습니다, 계속하세요”라고 말한다. 파서는 “덩어리를 하나 주면, 더 정밀한 타입으로 돌려주거나 왜 그렇게 못 하는지 알려주겠다”라고 말한다. 이 차이는 학술적인 얘기처럼 들리지만, 검증기는 실행이 끝나는 순간 자신이 알아낸 정보를 버려 버리고, 파서는 배운 것을 타입에 인코딩해서 보존한다 는 점을 깨닫는 순간 달라진다. 문자열을 EmailAddress로 한 번 파싱하고 나면, 프로그램의 나머지 부분은 다시는 그게 뭔지 고민할 필요가 없다. 마음은 편해지고, 재밌는 문제에 쓸 정신적 여유도 더 생긴다.
Haskell이나 Elm이나 F#에서는 그냥 이런 식으로 코드를 쓴다. 언어가 그 방향으로 끌고 간다. TypeScript에서는… 그렇지 않다. TypeScript는 올바른 일을 하도록 기꺼이 허용하지만, 그렇게 하라고 강제하지도 않고, 부드럽게 등을 떠밀어 주지도 않는다. 오히려 구조적 타이핑이 이 게임 전체를 적극적으로 방해한다.
무슨 뜻인지 보여주겠다.
내가 끊임없이 보는, 그리고 쓰는 코드의 종류는 이런 것이다.
interface User {
id: number;
email: string;
age: number;
}
// 실제 검증은 순진하고 단순하지만, 요지는 전달될 것이다:
function isValidUser(user: User): boolean {
if (!user.email.includes("@")) return false;
if (user.age < 0 || user.age > 150) return false;
return true;
}
function sendWelcome(user: User) {
if (!isValidUser(user)) {
throw new Error("invalid user");
}
// ...나중에, 호출 스택 더 깊은 곳에서:
emailService.send(user.email, `Welcome, age ${user.age}`);
}
거짓말이 어디 있는지 보이는가? User.email은 그저 string이다. User.age는 그저 number다. 검증은 이루어졌다 — 축하한다 — 하지만 타입 시스템은 isValidUser가 반환되는 순간 그 사실을 잊어버린다. 세 번쯤 더 깊은 함수 호출 안에서 누군가 user.email을 만질 때, 그것을 진짜 이메일을 기대하는 함수에 넘기지 못하게 막아 줄 것은 아무것도 없다. TypeScript 입장에서 그것은 그냥 문자열이기 때문이다. ""과도 같고, "hello"와도 같고, "definitely not an email"과도 같다.
그럼 우리는 뭘 할까? 다시 검증한다. if를 하나 더 추가한다. 단위 테스트를 쓴다. 잘되길 바란다. 원래 글에서 King은 이것을 훨씬 더 좋은 표현으로 부른다. “shotgun parsing” — 검증이 여기저기 흩어져 있지만, 아무것도 기억되지 않는 상태다.
우리가 원하는 것은 이것이다.
function sendWelcome(user: ValidUser) {
emailService.send(user.email, `Welcome, age ${user.age}`);
}
그리고 우리는 sendWelcome을 파서를 거치지 않은 어떤 값으로도 호출하는 것이 불가능 하기를 원한다. 재검사도 없고 “방어적 프로그래밍”도 없다. 말하자면 타입 자체가 증명이 된다.
Elm이라면 불투명 타입과 스마트 생성자를 집어 들고 네 줄쯤 만에 끝냈을 것이다. TypeScript에서는, 음, 적어도 가능은 하다. 다만 덜 유쾌할 뿐이다.
TypeScript는 구조적 타이핑을 한다. 즉 같은 모양을 가진 두 타입은 같은 타입이다. string은 string이고 또 string이다. newtype은 없다. Haskell처럼 진짜로 구별되는 타입을 만들어 주는 type EmailAddress = String 같은 것도 없다.
커뮤니티가 정착시킨 우회로는 브랜딩 이다. 태깅 이라고도 하고, 교차 타입을 이용한 명목적 타이핑 이라고도 한다. 값싼 버전은 문자열 리터럴 팬텀({ readonly __brand: "Email" })이고 어디서나 볼 수 있다. 조금 덜 값싼 버전은 모듈 밖으로 내보내지 않는 unique symbol을 사용해서, 외부에서는 그 브랜딩을 위조하려 해도 아예 이름조차 쓸 수 없게 만든다.
declare const EmailBrand: unique symbol;
declare const AgeBrand: unique symbol;
type Email = string & { readonly [EmailBrand]: true };
type Age = number & { readonly [AgeBrand]: true };
런타임에는 브랜드 필드가 없다. 이것은 “팬텀”이다. 타입 수준의 표식일 뿐이며, 컴파일 타임에 Email과 string을 서로 호환되지 않게 만든다. Email을 얻는 유일한 방법은 그 방법을 아는 함수를 거치는 것이다. 이 모듈 밖에서는 심볼의 이름조차 알 수 없으니 가짜로 만들어 낼 수도 없다. TS5에서는 템플릿 리터럴 타입과 약간 썸을 탈 수도 있다 — type Email = ${string}@${string}`` — 데모로는 재밌지만 그것만으로는 충분하지 않다. 바로 이 움직임 덕분에 언어를 떠나지 않고도 불법 상태를 표현 불가능하게 만들 수 있다.
참고로 브랜드는 단방향이다. Email은 여전히 string에 대입할 수 있다. 도메인 안으로 들어올 때는 명목적이고, 나갈 때는 구조적이다. 사실 이것이 정확히 우리가 원하는 동작이다.
그 함수가 바로 당신의 파서다.
type ParseError = { kind: "ParseError"; message: string };
type Parsed<T> = { kind: "ok"; value: T } | { kind: "err"; error: ParseError };
function parseEmail(raw: string): Parsed<Email> {
if (!raw.includes("@")) {
return { kind: "err", error: { kind: "ParseError", message: "missing @" } };
}
// 검사를 마쳤으니, 이제 의도적으로 타입 시스템에 거짓말한다
return { kind: "ok", value: raw as Email };
}
function parseAge(raw: unknown): Parsed<Age> {
if (
typeof raw !== "number" ||
!Number.isInteger(raw) ||
raw < 0 ||
raw > 150
) {
return { kind: "err", error: { kind: "ParseError", message: "bad age" } };
}
return { kind: "ok", value: raw as Age };
}
parseEmail 판정식은 민망할 정도로 얇다. 실제 구현이라면 공백을 제거하고, 소문자로 바꾸고, 적어도 도메인 부분 정도는 검증하는 시늉이라도 해야 한다. 하지만 그렇다고 블로그 글에서 이메일 파서를 쓰고 싶지는 않다. as Email은 조금 아프고, 실제로 그래야 한다. 규칙을 깰 수 있는 유일한 장소이기 때문이다. 파서는 신뢰된 경계다. 코드베이스의 다른 어디에서도 string에서 Email을 마법처럼 만들어 낼 수 없다. 반드시 parseEmail을 호출하고 두 분기를 모두 처리해야 한다. 참고로 일부러 불리언 판별자 대신 kind: "ok" | "err"를 썼다. 불리언은 깔끔해 보이지만 누군가 세 번째 경우를 추가하는 순간, 전체 소진성 검사가 조용히 작동하지 않게 된다. 문자열은 정직하게 좁혀진다.
이것을 처음의 “던지고 기도하는” 검증기와 비교해 보라. 그 실패 방식은 예외이고, 예외는 타입 시스템에 보이지 않는다. 반면 파서의 시그니처는 일어날 수 있는 모든 일을 알려 준다. 호출 스택 어딘가에 숨은 세 번째 선택지는 없다.
이제 도메인 타입이다. 나는 흔히 한데 섞여 버리는 두 가지에 이름을 붙이고 싶다. 와이어에서 막 넘어온 원시 덩어리와, 내가 신뢰할 자격을 얻은 대상이다.
declare const UserIdBrand: unique symbol;
type UserId = number & { readonly [UserIdBrand]: true };
type UnvalidatedUser = {
id: unknown;
email: unknown;
age: unknown;
};
type ValidUser = {
readonly id: UserId;
readonly email: Email;
readonly age: Age;
};
function parseUserId(raw: unknown): Parsed<UserId> {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
return { kind: "err", error: { kind: "ParseError", message: "bad id" } };
}
return { kind: "ok", value: raw as UserId };
}
function parseUser(raw: unknown): Parsed<ValidUser> {
if (typeof raw !== "object" || raw === null) {
return {
kind: "err",
error: { kind: "ParseError", message: "not an object" },
};
}
if (!("id" in raw) || !("email" in raw) || !("age" in raw)) {
return {
kind: "err",
error: { kind: "ParseError", message: "missing fields" },
};
}
if (typeof raw.email !== "string") {
return {
kind: "err",
error: { kind: "ParseError", message: "email not a string" },
};
}
const id = parseUserId(raw.id);
if (id.kind === "err") return id;
const email = parseEmail(raw.email);
if (email.kind === "err") return email;
const age = parseAge(raw.age);
if (age.kind === "err") return age;
return {
kind: "ok",
value: { id: id.value, email: email.value, age: age.value },
};
}
UnvalidatedUser와 ValidUser를 따로 이름 붙이는 것은 작지만 값진 DDD식 움직임이다. 무언가는 원시 상태로 들어오고, 무언가는 신뢰 가능한 상태로 나온다. 그리고 그 경계는 함수다. id도 브랜드 처리했다. 도메인의 모든 원시 타입은 놓친 대화 하나와 같고, OrderId가 필요한 곳에 UserId를 넘길 수 없다는 사실은 이 기법 전체에서 가장 값싼 승리 중 하나다. 더 이상 as Record<string, unknown>도 필요 없다. 타입 시스템에 거짓말하지 말자는 글을 쓰면서 타입 시스템에 거짓말할 수는 없지 않은가.
이것은 F#이나 Elm의 대응물보다 훨씬 못생겼다. 그 점을 아닌 척하진 않겠다. 에러 시 조기 반환 패턴은 라이브러리를 끌어오지 않았을 때 TypeScript가 Result 모나드에 가장 가깝게 갈 수 있는 방식인데, 반복적이 된다. 물론 Effect나 neverthrow, 혹은 fp-ts를 써서 이것을 더 깔끔하게 만들 수는 있다. 장난감보다 큰 것이라면 나도 그렇게 할 것이다. 하지만 여기서는 언어가 기본으로 주는 것을 보여주고 싶다. 문법이 살아남지 못하더라도 원칙은 살아남기 때문이다.
대가는 분명하다. 이제 sendWelcome(user: ValidUser)는 정말로 안전하다. parseUser를 거치지 않고 ValidUser를 만들어 내는 경로는 코드베이스 어디에도 없다. 타입 자체가 증명이다. 검증이 버려지지 않았다.
여전히 거슬리는 점이 몇 가지 있다.
첫째는 parseEmail 안에 있는 as Email 캐스트다. 진짜 명목적 언어에서는 스마트 생성자가 거짓말할 필요가 없다. 새 타입이 정말로 다른 타입이기 때문에 그대로 반환하면 된다. TypeScript에서는 브랜드가 허구이므로, 그것을 지나가려면 단언을 해야 한다. 여기서 필요한 규율은 이것이다. 그 단언은 파서만 할 수 있어야 한다. 코드베이스의 다른 곳으로 캐스트가 새어 나가는 순간, 이 전체 구상은 무너진다. 나는 요즘 파서들을 별도 모듈에 넣고, 그 모듈 바깥의 as Brand<...>는 버그로 취급하는 편이다. 사용자 정의 ESLint 규칙도 도움이 된다.
둘째는 소진성이다. 판별 유니언은 이런 스타일에서 TypeScript의 킬러 기능이다. Elm의 커스텀 타입에 언어가 가장 가까이 다가가는 지점이기 때문이다. 실제로 언어는 never 좁히기를 통해 소진성 검사를 해 준다. 다만 전용 match 표현식이 없어서, never 트릭을 손으로 써야 하고, 또 그걸 써야 한다는 사실도 기억해야 한다.
function describe(result: Parsed<ValidUser>): string {
switch (result.kind) {
case "ok":
return `user ${result.value.id}`;
case "err":
return `failed: ${result.error.message}`;
default: {
const _exhaustive: never = result;
return _exhaustive;
}
}
}
Parsed에 세 번째 변형을 추가하면 never 대입이 실패하고, 컴파일러가 정확히 어디를 봐야 하는지 알려 준다. Elm에서는 분기를 하나 빼먹는 순간, 말 그대로 무시할 수 없는 컴파일 오류가 된다.
그리고 이 얘기를 하는 김에 말하자면, 알아 둘 만한 또 다른 현대적 탈출구는 satisfies다. const x = { ... } satisfies Config는 타입에 맞는지 검사하면서도 넓혀 버리지는 않기 때문에, 정밀한 리터럴 타입을 유지한 채 안전성도 얻을 수 있다. 캐스트의 공손한 버전이라고 보면 된다.
셋째로 거슬리는 것은 JSON.parse다. 이것은 any를 반환하는데, any는 이 언어 최악의 타입이며 이 글이 존재하는 이유 그 자체다. 즉시 unknown으로 주석을 달아라. const raw: unknown = JSON.parse(input)처럼 말이다. 그 다음은 파서에게 맡기면 된다. JSON.parse는 검증기의 사악한 사촌이 아니다. 그것은 역직렬화기다. 바이트를 JS 값으로 바꾼다. 그 값이 User인지 여부는 완전히 별개의 질문이고, 바로 그 질문에 답하기 위해 파서가 존재한다.
Zod는 훌륭하다. io-ts도 그렇고, valibot도 그렇다. 써라. 방금 내가 손으로 쓴 모든 것의 인체공학적 버전이기 때문이다. 하나의 정의에서 파서와 TypeScript 타입을 함께 만들어 주는 스키마 우선 DSL이다.
import { z } from "zod";
const ValidUserSchema = z.object({
id: z.number().int(),
email: z.string().email().brand<"Email">(),
age: z.number().int().min(0).max(150).brand<"Age">(),
});
type ValidUser = z.infer<typeof ValidUserSchema>;
const result = ValidUserSchema.safeParse(rawInput);
safeParse는 { success: true, data } 또는 { success: false, error }를 반환한다. 위에서 내가 만든 것과 같은 모양이고, 필드 이름만 다르다. .brand() 호출은 손수 만든 심볼 트릭과 정확히 마찬가지로 순전히 타입 수준에서만 작동한다. 런타임에는 아무 일도 일어나지 않는다. 여기서 얻는 것은 하나의 정의로부터 파서와 타입을 함께 얻는다는 점이고, 이는 몇 절 전에 손으로 강제하라고 했던 파서/타입 공존 경계를 구조적으로 강제해 준다. 그것만으로도 의존성을 추가할 가치가 있다.
하지만 — 내가 계속해서 다시 돌아오게 되는 부분은 바로 이것이다 — Zod는 사고방식의 문제 를 바꾸지 못한다. 그저 올바른 일을 더 쉽게 만들 뿐이다. 여전히 모든 경계에서 그것을 쓰기로 선택해야 한다. 여전히 오류 메시지에서 빠져나오려고 타입 단언으로 도망치고 싶은 유혹을 버텨야 한다. 여전히 네트워크에서 온 User는 어떤 것이 그것을 파싱하기 전까지는 User가 아니라는 사실을 기억해야 한다. 라이브러리는 도구다. 규율은 당신 몫이다.
이 점은 Why TypeScript Won’t Save You에서 잠깐 언급한 적이 있는데, 요지는 같다. 언어가 경계를 강제해 주지 않으니, 당신이 직접 강제해야 한다.
King의 아이디어를 밤 11시에 배포 직전에도 떠올릴 수 있는 한 문장으로 압축해야 한다면, 나는 이렇게 말할 것이다. 증명은 당신의 기억이 아니라 타입 시스템이 짊어지게 하라. 무언가를 검사하고도 그 결과를 타입에 인코딩하지 않는 매 순간, 당신은 미래의 자신에게 기억해 달라고 부탁하고 있는 것이다. 미래의 당신은 기억하지 못한다. 미래의 당신은 세 시간 잔 상태로 전혀 다른 버그를 디버깅하고 있고, 검증은 당연히 이미 됐을 거라고 가정할 것이다. 여기 이렇게 많은 if 문이 있지 않은가. 검증기는 새어 나간다. 파서는 그렇지 않다.
TypeScript에서 이것은, 언어가 마지못해 주는 것들이더라도, 그 세 가지에 기대라는 뜻이다. 명목성 비슷한 정체성을 위한 브랜디드 타입, 정직한 오류 처리를 위한 판별 유니언, 그리고 unknown(바깥에서 들어온 것)과 도메인 타입(신뢰할 자격을 얻은 것) 사이의 엄격한 경계. 그 어느 것도 Elm만큼 깔끔하지는 않다. 하지만 전부 대안보다는 낫다.
나도 여전히 때때로 검증기를 쓴다. 내가 만지는 모든 코드베이스를 파싱 파이프라인으로 리팩터링한다고 가장할 생각은 없다. 그건 거짓말일 뿐 아니라, 아마 시간 사용 면에서도 좋지 않을 것이다. 하지만 서로 다른 세 파일에서 같은 것을 검사하는 세 번째 방어적 if를 추가하고 있는 자신을 발견할 때면, 무슨 일이 일어났는지는 안다. 파싱했어야 할 때 검증을 해 버린 것이다. 정보는 거기에 있다. 다만 타입 안에 들어 있지 않을 뿐이다.
보통 그럴 때 나는 다시 King의 글을 한 번 더 읽으러 간다.