본문으로 건너뛰기
2026. 7. 13
© WONKOOK LEE

타입스크립트로 잘못된 상태 막기

표현 가능한 전체 상태 공간 안에서 유효한 상태만 남기고 잘못된 상태를 도려낸 그림

결제 상태를 담은 객체가 하나 있었습니다. isPaid, isRefunded 두 개의 불리언과, 각각의 시각을 담는 paidAt, refundedAt. 언뜻 자연스러운 모델입니다. 그런데 어느 날 새벽 정산 배치가 넘어졌습니다. isRefundedtrue인데 refundedAtnull인 주문이 하나 섞여 있었고, 환불 시각을 기준으로 장부를 맞추던 코드가 그 null을 만나 그대로 멈춘 것입니다.

그런 주문을 만들라고 코드를 쓴 사람은 없습니다. 어딘가에서 플래그만 켜고 짝이 되는 시각을 채우는 걸 빠뜨렸을 뿐입니다. 문제는 그 빠뜨림이 가능했다는 것입니다. 플래그 하나와 그 짝인 시각이 서로 어긋난 조합은 있어선 안 되는데도, 타입 위에는 그 자리가 버젓이 마련돼 있었습니다. 타입이 그 상태를 멀쩡히 허락하고 있었던 겁니다.

저는 정산 코드에 if (order.isRefunded && !order.refundedAt) 같은 가드를 한 줄 넣었습니다. 며칠 뒤 다른 배치에서 비슷한 가드를 또 넣었습니다. 방어 코드는 늘어나는데 "그럼 나머지 자리는 다 괜찮나?"라는 불안은 줄지 않았습니다.

버그가 난 자리를 하나씩 틀어막는 대신, 그 어긋난 상태를 애초에 코드로 표현조차 할 수 없게 만들 수는 없었을까요?


이 글에서 다루는 내용

이 글은 함수형 프로그래밍 진영에서 나온 설계 원칙 하나 — make illegal states unrepresentable(잘못된 상태를 표현 불가능하게 만들기) — 를 TypeScript로 풀어봅니다. 거창한 이름이지만 뜻은 단순합니다. "말이 안 되는 상태는 컴파일조차 되지 않게 하자"입니다.

이걸 세 개의 축으로 나눠서 다루겠습니다 — 상태를 좁히는 태그 유니온, 원시값에 의미를 입히는 브랜디드 타입, 불변식을 각인하는 parse-don't-validate. 그 가운데 브랜디드/오파크 타입은 조금 더 깊이 파겠습니다. 타입 시스템을 처음 진지하게 들여다보는 분도 따라올 수 있도록, 낯선 용어는 나올 때마다 풀어서 설명하겠습니다.





1. "표현 가능한 잘못된 상태"라는 문제

먼저 이 원칙이 어디서 왔는지부터. "make illegal states unrepresentable"은 Jane Street의 Yaron Minsky가 OCaml로 견고한 시스템을 만드는 법을 이야기하며 대중화한 표현입니다. 이후 F#, Elm, Haskell 같은 언어 커뮤니티에서 하나의 격언처럼 자리 잡았고, TypeScript에도 거의 그대로 옮겨올 수 있습니다.

핵심 아이디어를 이해하려면 상태 공간(state space)이라는 개념을 먼저 잡아야 합니다. 어떤 타입 하나는 곧 "이 타입이 가질 수 있는 값들의 집합"입니다. booleantrue, false 두 개짜리 집합이고, 객체 타입은 각 필드가 가질 수 있는 값들의 조합 전체입니다. 이 집합의 크기를 그 타입의 기수(cardinality, 값의 개수)라고 부릅니다.

여기서 중요한 건 이겁니다. 타입이 표현할 수 있는 상태의 집합과, 실제로 말이 되는(유효한) 상태의 집합은 다릅니다. 그 둘의 차이 — 표현은 되는데 유효하지는 않은 영역 — 이 바로 "잘못된 상태"이고, 서두의 버그가 살던 곳입니다.

바깥 상자(표현 가능한 전체)에서 안쪽 상자(유효한 상태)를 뺀 영역이 곧 잘못된 상태입니다.


곱하기냐 더하기냐

이 차이를 눈으로 보려면 타입을 대수적 자료형(algebraic data type)의 관점에서 세어보면 됩니다. 이름은 무섭지만 규칙은 초등학교 산수입니다.

  • 곱 타입(product type) — 필드를 그리고(AND)로 묶는 것. 객체가 여기 해당합니다. 기수는 각 필드 기수의 입니다. { a: boolean; b: boolean }2×2=42 \times 2 = 4가지.
  • 합 타입(sum type) — 여러 경우를 또는(OR)으로 묶는 것. 유니온(A | B)이 여기 해당합니다. 기수는 각 경우 기수의 입니다.

서두의 결제 상태를 이 자로 재보겠습니다.

// 결제 상태를 네 필드의 곱(product)으로 표현한 경우
interface Order {
isPaid: boolean; // 2가지
isRefunded: boolean; // 2가지
paidAt: Date | null; // (시각 + null)
refundedAt: Date | null; // (시각 + null)
}

이 타입의 기수는 대략 2×2×(Date+1)22 \times 2 \times (|Date| + 1)^2입니다. 곱이기 때문에 필드가 늘수록 상태 공간이 폭발합니다. 게다가 isPaidpaidAt은 사실 같은 사실을 두 번 말하는 중복이라 — 결제됐다면 시각이 있어야 하고, 아니라면 없어야 합니다 — 저 조합의 상당수는 서로 모순됩니다. 그런데 이 중 우리가 실제로 원한 상태는 딱 세 가지뿐입니다.

우리가 원한 상태표현 가능한 잘못된 상태
결제 전 — 아무것도 없음isRefunded: true 인데 isPaid: false (결제도 안 했는데 환불)
결제 완료 — paidAt 있음isPaid: true 인데 paidAt: null (결제됐다면서 시각이 없음)
환불 완료 — 두 시각 다 있음isRefunded: true 인데 refundedAt: null ← 서두의 버그

곱 타입은 유효하지 않은 조합까지 전부 "표현 가능"으로 만들어 버립니다. 방어 코드(if (order.isRefunded && !order.refundedAt))는 이 표현 가능한 공간을 런타임에 다시 좁히려는 노력입니다. 하지만 그건 이미 벌어진 일을 수습하는 것이고, 빠뜨리면 그대로 버그가 됩니다.

원칙이 말하는 방향은 정반대입니다. 타입 자체의 기수를 유효한 상태의 개수까지 끌어내려서, 잘못된 조합이 애초에 타입에 존재하지 않게 하자. 그러면 방어 코드가 필요 없습니다. 잊어버릴 코드가 없으니까요.


Recap

타입 하나는 그것이 가질 수 있는 값들의 집합이고, 그 집합의 크기를 기수라고 합니다. 문제는 타입이 표현할 수 있는 상태와 실제로 유효한 상태가 다르다는 것 — 그 틈이 잘못된 상태입니다. 객체(곱 타입)는 필드 기수를 곱하기 때문에 유효하지 않은 조합까지 폭발적으로 표현 가능하게 만듭니다. 이 글의 목표는 타입의 기수를 유효한 상태의 개수까지 좁혀, 잘못된 상태를 방어 코드가 아니라 타입으로 봉쇄하는 것입니다.




2. 출발점: 구조적 타이핑은 무엇을 잡고 무엇을 놓치나

본격적인 기법으로 들어가기 전에, TypeScript 타입 시스템의 성격 하나를 짚어야 합니다. 이걸 알아야 3장은 왜 쉽고 4장은 왜 품이 드는지가 설명됩니다.

TypeScript는 구조적 타이핑(structural typing)을 씁니다. "모양(구조)이 같으면 같은 타입으로 취급한다"는 규칙입니다. 이름표가 아니라 생김새로 판단하는, 이른바 덕 타이핑(duck typing)입니다.

interface Point {
x: number;
y: number;
}

function print(p: Point) {}

// Point라고 선언한 적 없는 객체지만, 모양이 같으니 통과한다
const vec = { x: 1, y: 2 };
print(vec); // ✅

반대편에는 명목적 타이핑(nominal typing)이 있습니다. Java, C#, Rust처럼 "이름이 다르면 다른 타입"으로 보는 방식입니다. 거기서는 모양이 같아도 다른 클래스면 못 섞습니다.

구조적 타이핑은 유연함이라는 큰 장점을 줍니다. 3장에서 볼 유니온과 객체 조합이 자연스럽게 잘 맞물리는 것도 이 성격 덕분입니다. 그런데 원시값을 다룰 때는 이 유연함이 정확히 발목을 잡습니다.

type UserId = string;
type OrderId = string;

function cancelOrder(id: OrderId) {}

const userId: UserId = "u_123";
cancelOrder(userId); // ✅ 통과 — 논리적으론 명백한 버그인데

UserIdOrderId도 결국 string이라, 구조적으로는 완전히 같은 타입입니다. 컴파일러 입장에선 둘을 구별할 근거가 없습니다. 사용자 아이디로 주문을 취소하는, 절대 일어나선 안 되는 호출이 아무 경고 없이 통과합니다. 이건 1장에서 말한 "표현 가능한 잘못된 상태"의 또 다른 얼굴입니다.

정리하면 이렇습니다. 구조적 타이핑은 여러 값을 조합해 만든 상태(3장)를 좁히는 데는 우리 편이지만, 같은 원시 타입에 서로 다른 의미를 부여하는 일(4장)에는 오히려 반대편에 섭니다. 4장에서 우리가 하려는 것은 이 구조적 시스템 위에 명목적 성질을 인위적으로 얹는 일입니다.


Recap

TypeScript는 모양이 같으면 같은 타입으로 보는 구조적 타이핑을 씁니다. 이름이 다르면 다른 타입으로 보는 명목적 타이핑과 대비됩니다. 구조적 성격은 유연함을 주지만, UserIdOrderId처럼 둘 다 string인 값들을 구별하지 못해 원시값을 섞어 쓰는 버그를 그대로 통과시킵니다. 상태 조합을 좁히는 일에는 이 성격이 도움이 되고, 원시값에 의미를 부여하는 일에는 별도의 장치가 필요합니다.




3. 상태를 좁히기: 태그가 있는 유니온과 소진 검사

첫 번째 축은 서두의 결제 상태를 다시 설계하는 것입니다. 곱 타입으로 벌려놓은 상태를, 경우들의 으로 다시 씁니다.

// 세 가지 경우의 합(sum)으로 표현 — 시각은 그것이 유효한 경우에만, null 없이 존재한다
type Order =
| { status: "pending" }
| { status: "paid"; paidAt: Date }
| { status: "refunded"; paidAt: Date; refundedAt: Date };

이 타입을 태그가 있는 유니온(discriminated union, 판별 유니온)이라고 부릅니다. 각 경우가 status라는 공통 필드를 갖고, 그 값("pending", "paid" …)이 서로 겹치지 않는 리터럴이라는 점이 핵심입니다. 이 구별용 필드를 판별자(discriminant / tag)라고 합니다. 컴파일러는 이 태그를 보고 "지금 어느 경우인지"를 정확히 좁혀낼 수 있습니다.

바뀐 것을 보세요. refundedAt은 이제 refunded일 때만, 그것도 null일 수 없는 채로 존재합니다. 서두에 정산을 넘어뜨린 "환불됐다는데 환불 시각이 없는" 조합은 이 타입으로는 애초에 적을 수가 없습니다. 플래그와 시각을 따로 들고 있지 않으니 둘이 어긋날 방법 자체가 없습니다. 1장의 표현으로 하면, 기수가 곱(2×2×2 \times 2 \times \dots)에서 합(1+Date+Date21 + |Date| + |Date|^2)으로 내려앉으면서 모순된 조합이 통째로 사라진 것입니다.

각 상태는 자기에게 유효한 필드만 듭니다. refundedAtrefunded에만 있으니 플래그와 어긋날 자리가 없습니다.


never와 소진 검사

여기에 하나를 더 얹으면 타입이 미래의 실수까지 막아줍니다. 상태를 처리하는 코드를 이렇게 씁니다.

function describe(order: Order): string {
switch (order.status) {
case "pending":
return "결제 대기";
case "paid":
return `${order.paidAt.toISOString()} 결제 완료`; // paid에서만 paidAt 접근 가능
case "refunded":
return `${order.refundedAt.toISOString()} 환불 완료`; // refunded에서만 refundedAt 접근 가능
default:
return assertNever(order); // ← 여기가 안전장치
}
}

// 도달하면 안 되는 곳임을 타입으로 선언하는 함수
function assertNever(value: never): never {
throw new Error(`처리하지 않은 상태: ${JSON.stringify(value)}`);
}

여기서 never라는 타입을 설명하고 넘어가야겠습니다. never값이 하나도 없는 타입, 즉 공집합입니다. boolean이 값 2개짜리 집합이라면 never는 0개짜리 집합입니다. "이 자리에 올 수 있는 값은 존재하지 않는다"를 타입으로 말하는 방법입니다.

이게 왜 안전장치가 되는지가 재미있습니다. switch에서 모든 경우를 처리하고 나면, default에 도달한 order는 컴파일러가 보기에 "남은 가능성이 하나도 없는" 값 — 즉 never로 좁혀집니다. 그래서 assertNever(order)가 통과합니다. 그런데 만약 누군가 Order에 네 번째 경우(가령 지급 분쟁을 뜻하는 { status: "disputed" })를 추가하고 이 switch는 안 고쳤다면, defaultorder에는 disputed 가능성이 남아 있어 더 이상 never가 아닙니다. assertNevernever만 받는데 never가 아닌 걸 넘겼으니 컴파일 에러가 납니다.

즉 상태를 하나 추가하는 순간, 그걸 처리하지 않은 모든 자리를 컴파일러가 줄줄이 짚어줍니다. 이걸 소진 검사(exhaustiveness checking)라고 합니다. "모든 경우를 빠짐없이 다뤘는가"를 사람의 주의력이 아니라 타입 검사기가 보증하는 것입니다.


Recap

곱 타입으로 벌어져 있던 상태를 태그가 있는 유니온으로 다시 쓰면, 각 경우가 자기에게 필요한 필드만 들고 있어 잘못된 조합을 아예 적을 수 없게 됩니다. 판별자(태그) 덕분에 컴파일러는 지금이 어느 경우인지 정확히 좁혀냅니다. 여기에 값이 하나도 없는 타입인 never를 이용한 assertNever를 더하면, 새 경우가 추가될 때 그것을 처리하지 않은 코드를 컴파일러가 전부 짚어주는 소진 검사가 됩니다.




4. 원시값에 의미 입히기: 브랜디드/오파크 타입

3장이 "여러 값을 조합한 상태"를 다뤘다면, 이 장은 2장에서 미뤄둔 문제 — UserIdOrderId가 둘 다 string이라 구별되지 않는 문제 — 를 정면으로 다룹니다. 세 축 중 가장 깊이 들어갈 부분입니다.

문제의 뿌리는 구조적 타이핑이었습니다. 그렇다면 해법은 구조에 인위적인 차이를 만들어, 겉보기엔 string이지만 서로 다른 타입으로 취급되게 하는 것입니다. 이 기법을 브랜디드 타입(branded type)이라고 합니다.

// T에 B라는 '상표(brand)'를 찍는 도구
type Brand<T, B> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">; // string 이면서 "UserId" 상표가 찍힘
type OrderId = Brand<string, "OrderId">; // string 이면서 "OrderId" 상표가 찍힘

Brand<T, B>는 교차 타입(intersection, T & U — "T이면서 동시에 U")으로 원래 타입에 __brand라는 꼬리표를 붙입니다. 이렇게 만든 UserId는 여전히 문자열의 모든 연산(.length, .slice() …)을 그대로 쓸 수 있지만, OrderId와는 __brand의 값이 달라서 더 이상 호환되지 않습니다.

function cancelOrder(id: OrderId) {}

const userId = "u_123" as UserId;
cancelOrder(userId); // ❌ UserId는 OrderId에 할당할 수 없음 — 2장의 버그가 이제 막힘
cancelOrder("o_999"); // ❌ 순수 string도 거부 — 반드시 상표를 거쳐야 함

__brand는 실제로 존재하지 않습니다 — 팬텀 타입

여기서 가장 헷갈리기 쉬운 지점을 짚겠습니다. __brand 프로퍼티는 런타임에 실제로 존재하지 않습니다. userId를 콘솔에 찍어보면 그냥 문자열 "u_123"입니다. __brand라는 속성은 어디에도 없습니다.

이렇게 타입 검사기의 머릿속에만 존재하고 런타임에는 흔적이 없는 타입 수준의 표식팬텀 타입(phantom type)이라고 부릅니다. 유령처럼 컴파일 타임에만 나타났다가 컴파일이 끝나면 사라진다는 뜻입니다. 원래 ML/Haskell 계열에서 온 개념인데, TypeScript에서 브랜디드 타입은 이 팬텀 타입을 흉내 내 만든 것입니다. 그래서 브랜디드 타입은 런타임 비용이 0입니다. 타입만 다를 뿐, 만들어지는 값은 순수한 string 그대로입니다.

이렇게 구조적 타입 시스템 위에 "이름이 다르면 다른 타입"이라는 명목적 성질을 인위적으로 얹는 것을, 명목적 타이핑 흉내내기(nominal typing emulation)라고 부릅니다. 2장에서 예고한 그 작업이 바로 이것입니다.

상표는 타입 검사기 안에만 살다가 컴파일되면 지워집니다 — 그래서 런타임 비용이 0입니다.


상표를 위조하지 못하게 — unique symbol

위의 __brand: "UserId"는 사실 허점이 있습니다. 누군가 다른 파일에서 우연히 똑같이 __brand: "UserId"를 쓰면 상표가 충돌할 수 있고, 마음만 먹으면 손으로 그 객체를 흉내 내 만들 수도 있습니다. 이걸 더 단단히 하려면 unique symbol을 씁니다.

// 이 심볼은 이 파일 안에서만, 단 하나로 존재한다 — 밖에서 같은 것을 만들 수 없다
declare const brand: unique symbol;

type Brand<T, B> = T & { readonly [brand]: B };

unique symbol은 "세상에 단 하나뿐임이 타입으로 보장되는 심볼"입니다. declare const로 선언만 하고 실제 값은 만들지 않으므로(역시 런타임 흔적 없음), 이 brand 키를 모르는 외부 코드는 이 상표가 찍힌 객체를 손으로 위조할 수 없습니다. 상표를 붙이는 통로가 좁아지는 것입니다.


브랜디드 vs 오파크 — 강조점의 차이

가까운 사촌으로 오파크 타입(opaque type)이 있습니다. 둘은 거의 함께 쓰이지만 강조점이 다릅니다.

브랜디드(branded)오파크(opaque)
핵심 목적같은 구조의 타입을 서로 구별내부 표현을 은닉(캡슐화)
관점"실수로 섞이지 않게""밖에서 내부가 string인지도 모르게"
전형적 구현교차 타입 + 상표모듈 밖에 생성 통로(생성자 함수)만 공개

실무에서는 둘을 합쳐 씁니다. 상표를 붙이되(브랜디드), 그 상표를 붙이는 통로를 검증 함수 하나로만 열어두는(오파크) 방식입니다.

// email.ts
declare const brand: unique symbol;

// 타입 이름만 밖으로 공개한다. brand 심볼은 공개하지 않는다.
export type Email = string & { readonly [brand]: "Email" };

// Email을 만드는 유일하게 '정당한' 통로
export function parseEmail(input: string): Email | null {
// 아주 단순화한 검사
return /^[^@\s]+@[^@\s]+$/.test(input) ? (input as Email) : null;
}

이제 Email이라는 타입이 있는 곳이라면, 그 값은 반드시 parseEmail을 통과했다는 사실이 타입으로 증명됩니다. 검증되지 않은 문자열은 Email 자리에 들어갈 수 없습니다. 5장은 바로 이 아이디어의 확장입니다.

한 가지는 솔직하게 짚고 넘어가겠습니다. TypeScript에는 as라는 강제 단언(type assertion)이 늘 열려 있어서, 밖에서 "아무거나" as Email이라고 우겨 쓰면 컴파일러는 막지 못합니다. 그래서 이 은닉은 언어가 물리적으로 강제하는 게 아니라 "상표를 찍는 as는 생성자 함수 안에서만 쓴다"는 규율로 지켜집니다(lint 규칙으로 보조할 수 있습니다). 브랜디드 타입이 막아주는 것은 실수로 인한 생성입니다. 의도적인 우회는 사람의 몫으로 남습니다 — 그럼에도, 실수를 막는 것만으로도 대부분의 버그는 사라집니다.


Recap

UserIdOrderId처럼 구조가 같아 구별되지 않는 원시값은, 교차 타입으로 상표를 찍는 브랜디드 타입으로 구별할 수 있습니다. 이 상표(__brand)는 런타임에 존재하지 않고 타입 검사기 안에만 사는 팬텀 타입이라 비용이 0입니다. 이는 구조적 시스템 위에 명목적 성질을 얹는 명목적 타이핑 흉내내기이며, unique symbol로 상표 위조를 막고, 생성 통로를 검증 함수 하나로 좁히면(오파크) "이 타입을 가진 값은 검증을 통과했다"는 사실까지 타입으로 보증됩니다. 다만 as라는 탈출구가 있어 최종적인 규율은 사람이 지켜야 합니다.




5. 불변식을 각인하기: Parse, don't validate

세 번째 축은 4장의 parseEmail을 하나의 원칙으로 끌어올립니다. Alexis King이 쓴 유명한 글의 제목이기도 한 "Parse, don't validate"(검증하지 말고 파싱하라)입니다.

먼저 불변식(invariant)이라는 말부터. 불변식은 "이 값이 유효하려면 언제나 참이어야 하는 조건"입니다. "이메일에는 @가 있다", "이 배열은 비어 있지 않다", "이 수는 0보다 크다" 같은 것들입니다.

이 불변식을 다루는 흔한 방식이 검증(validate) 입니다.

// 검증: 참/거짓만 알려주고 값의 타입은 그대로 둔다
function isValidEmail(input: string): boolean {
return /^[^@\s]+@[^@\s]+$/.test(input);
}

function sendMail(to: string) {
if (!isValidEmail(to)) throw new Error("invalid");
// ... to는 여기서도 여전히 그냥 string이다
}

검증의 문제는, 검사를 통과하고 나서도 손에 쥔 값의 타입이 그대로 넓은 채(string)라는 점입니다. 그래서 이 값이 다음 함수로 넘어가면, 그 함수는 "이게 검증된 이메일인지"를 알 방법이 없어 또 검사해야 합니다. 같은 검사가 코드 곳곳에 흩뿌려지고, 어느 한 곳에서 빠지면 버그가 됩니다. Alexis King은 이걸 "shotgun parsing"(산탄총 파싱 — 검증 로직이 여기저기 흩어진 안티패턴)이라고 불렀습니다.

파싱(parse) 은 태도가 다릅니다. 넓은 타입(string)을 받아서, 검사에 통과하면 더 좁은 타입(Email)으로 바꿔서 돌려줍니다. 검증했다는 사실이 값의 타입 안에 각인되는 것입니다.

// 파싱: 통과하면 더 좁은 타입으로 바꿔 돌려준다 (4장의 그 함수)
function parseEmail(input: string): Email | null {
/* ... */
}

function sendMail(to: Email) {
// to는 이미 Email이다. 다시 검사할 필요가 없다 — 타입이 증명한다.
}

이 원칙의 실천 지침은 "검사는 경계에서 한 번만" 입니다. 외부에서 들어온 값(HTTP 요청, 폼 입력, DB 응답)을 시스템 안으로 들이는 바로 그 지점에서 파싱해 좁은 타입으로 바꾸고, 그 뒤의 코어 로직은 이미 좁혀진 타입만 다룹니다. 그러면 코어에는 "혹시 이거 유효한가?"를 묻는 코드가 없어집니다. 이미 유효함이 타입으로 보장되니까요.

검사는 시스템 경계에서 한 번. 그 안쪽은 이미 좁혀진 타입만 흐릅니다.

이걸로 세 축이 하나의 그림으로 모입니다. 잘못된 상태를 표현 불가능하게 만드는 일은, 결국 표현 가능하지만 유효하지 않은 영역을 세 방향에서 깎아내는 것입니다.

  • 상태의 잘못된 조합 → 태그가 있는 유니온으로 (3장)
  • 원시값의 뒤섞임 → 브랜디드 타입으로 (4장)
  • 불변식 위반 → 경계에서의 파싱으로 (5장)

세 축 모두, 하는 일은 같습니다. 타입의 기수를 유효한 상태의 개수 쪽으로 끌어내리는 것입니다.


Recap

불변식은 값이 유효하려면 늘 참이어야 하는 조건입니다. 검증(validate)은 참/거짓만 돌려주고 값의 타입을 넓은 채로 남겨, 같은 검사가 곳곳에 반복되게 만듭니다. 파싱(parse)은 넓은 타입을 좁은 타입으로 바꿔 돌려줘 "검증됨"을 타입에 각인하고, 검사를 시스템 경계에서 한 번만 하도록 만듭니다. 태그 유니온(상태)·브랜디드 타입(원시값)·파싱(불변식) 세 축은 모두 표현 가능하지만 유효하지 않은 영역을 깎아내는 같은 일을 합니다.




6. 어디까지 할 것인가

여기까지 오면 "그럼 전부 이렇게 짜야 하나?"라는 생각이 들 수 있습니다. 그렇지는 않습니다. 이 원칙은 공짜가 아닙니다.

  • 보일러플레이트와 인지 비용. 브랜디드 타입마다 생성자 함수가 필요하고, 처음 보는 사람에게는 Brand<...>unique symbol이 낯섭니다.
  • 직렬화 경계. 브랜디드 타입은 런타임에 흔적이 없으니 JSON을 건너면 상표가 사라집니다. 그래서 네트워크·DB 경계에서 값이 드나들 때마다 다시 파싱해줘야 합니다. (이건 5장의 "경계에서 파싱"과 오히려 잘 맞습니다.)
  • as 탈출구. 4장에서 봤듯 강제 단언이 열려 있어, 은닉은 규율로 지켜야 합니다.

그래서 판단 기준은 "멋있으니까 전부"가 아니라 "버그의 비용이 이 품을 정당화하는가" 입니다.

품을 들일 값어치가 큰 곳:

  • 뒤섞이면 큰 사고가 나는 식별자 — UserId / OrderId, 특히 권한·결제와 얽힌 것.
  • 단위나 통화 — USD / KRW, Meter / Feet처럼 섞으면 조용히 틀리는 값.
  • 검증이 반드시 선행돼야 하는 값 — 이메일, 비어 있지 않은 배열, 양의 정수.
  • 오래 살아남을 도메인 코어. 오래 볼 코드일수록 타입이 문서 역할까지 합니다.

반대로 하루 쓰고 버릴 스크립트나 내부에서만 잠깐 쓰는 모양에까지 이걸 두르는 건 과합니다.

결국 이 원칙의 본질은 특정 기법이 아니라 검사의 시점을 런타임에서 컴파일 타임으로 옮기는 것입니다. 런타임 검사는 그 코드가 실행될 때, 그것도 그 경로를 밟았을 때만 버그를 잡습니다. 타입 검사는 코드를 짜는 그 순간, 모든 경로에 대해, 예외 없이 잡습니다. 잘 설계한 타입은 지치지 않는 리뷰어처럼, 잘못된 상태를 적으려는 순간 손을 잡아줍니다. 서두의 저는 방어 코드를 한 줄씩 늘려가고 있었습니다. 지금이라면 그 자리에서 타입을 고쳤을 것입니다 — 그 잘못된 상태가 애초에 적히지 않도록.


Recap

잘못된 상태를 표현 불가능하게 만드는 일은 공짜가 아닙니다 — 보일러플레이트, 직렬화 경계에서의 재파싱, as 탈출구라는 비용이 있습니다. 그래서 식별자·단위·통화·검증 선행 값·오래 살 도메인 코어처럼 버그 비용이 큰 곳에 골라 씁니다. 원칙의 본질은 검사의 시점을 런타임에서 컴파일 타임으로 옮기는 것이고, 잘 설계한 타입은 모든 경로에 대해 예외 없이 잘못된 상태를 사전에 막아줍니다.




References

원칙의 출처

  1. Yaron Minsky — Effective ML Revisited (Jane Street Tech Blog)
  2. Scott Wlaschin — Designing with types: Making illegal states unrepresentable (F# for fun and profit)
  3. Richard Feldman — Making Impossible States Impossible (elm-conf 2016)

Parse, don't validate

  1. Alexis King — Parse, don't validate

TypeScript 타입 시스템

  1. TypeScript Handbook — Narrowing (discriminated unions · never · 소진 검사)
  2. TypeScript Handbook — unique symbol
  3. basarat — TypeScript Deep Dive: Nominal Typing (브랜디드 타입)


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자