타입스크립트 심화 #5 Discriminated union과 타입 가드 깊이
#3 conditional types와 infer 에서 union을 분배 처리하는 도구를 봤습니다. 이번 글은 한 발짝 뒤로 물러서서, 여러 모양의 데이터를 union으로 모델링하는 방법 자체를 깊이 다룹니다.
기초 강좌 #4 에서 narrowing의 기본을, 실전 #3에서 reducer action을 봤지만, 그건 시작이었어요. 이 글에서는 그 위에 사용자 정의 타입 가드, assertion 함수, 그리고 branded types 까지 얹습니다.
Discriminated union 다시 보기
핵심 한 줄로:
모든 멤버가 같은 이름의 리터럴 필드를 가지면 discriminated union이 된다.
type Result =
| { ok: true; data: string }
| { ok: false; error: string };
function handle(r: Result) {
if (r.ok) {
console.log(r.data); // 여기서 r은 { ok: true; data: string }
} else {
console.error(r.error); // 여기서 r은 { ok: false; error: string }
}
}ok 가 discriminator 입니다. 이 자리에 boolean이든 string literal이든 정확히 분기될 수 있는 값이면 충분해요. kind, type, status 같은 이름이 흔하지만, 꼭 이름이 있어야 하는 건 아닙니다.
한 멤버에만 있는 필드도 discriminator가 됨
다른 멤버에 같은 이름의 필드가 없을 때도 분기 됩니다. 다음 예제처럼요.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rect'; width: number; height: number };
function area(s: Shape) {
if ('radius' in s) return Math.PI * s.radius ** 2; // circle
if ('side' in s) return s.side ** 2; // square
return s.width * s.height; // rect
}'radius' in s 같은 in 연산자도 narrowing에 동원됩니다. 다만 discriminator를 명시적으로 두는 쪽(이 예제의 kind)이 보통 더 깔끔합니다. in 패턴은 외부 데이터처럼 우리가 모양을 못 정한 자리에 어울려요.
Exhaustiveness 검사 — never 패턴
union에 새 멤버를 추가했는데 switch 에서 처리를 빠뜨리면? 컴파일이 잡아 주게 만들 수 있습니다.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
default: {
const _exhaustive: never = s; // 멤버를 까먹으면 여기서 ✗
return 0;
}
}
}Shape 에 'rect' 를 추가하면 default 에서 s 가 { kind: 'rect'; ... } 가 되는데, 이걸 never 변수에 할당하려고 시도하니까 빨간 줄이 납니다. 새 멤버가 들어오면 처리하지 않은 곳에서 컴파일이 막혀 버그를 미리 잡아요.
사용자 정의 타입 가드 — value is X
기초 강좌에서 typeof/instanceof/in 으로 narrowing이 일어나는 걸 봤습니다. 이걸 함수로 캡슐화 할 수 있는데, 그게 사용자 정의 타입 가드(user-defined type guard) 예요.
type User = { id: string; name: string };
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return typeof v.id === 'string' && typeof v.name === 'string';
}
function greet(value: unknown) {
if (isUser(value)) {
console.log(`Hello, ${value.name}`); // 여기서 value는 User
}
}반환 타입이 boolean 이 아니라 value is User 입니다. "이 함수가 true를 반환하면, 호출하는 쪽에서는 value를 User로 좁히세요" 라는 약속이에요.
이 패턴이 #6 fetch와 API 응답 타이핑 에서 외부 데이터를 좁힐 때 등장했죠. 그 자리의 동작 원리가 이거였습니다.
위험성 — 거짓말이 가능합니다
타입 가드 함수의 반환은 개발자가 직접 결정합니다. 안에서 검사를 빠뜨려도 컴파일러는 알아채지 못해요.
function isUser(value: unknown): value is User {
return true; // 컴파일러는 통과시키지만, 실제로는 거짓말
}
const x: unknown = 42;
if (isUser(x)) {
console.log(x.name); // 컴파일은 통과. 런타임에 undefined.name 으로 폭발
}타입 가드를 만들 때는 실제 검증 로직과 반환 시그니처가 일치해야 안전합니다. 큰 규모에서는 zod 같은 스키마로 검증과 가드를 한 번에 표현하는 게 안전해요.
Assertion 함수 — asserts value is X
타입 가드의 사촌. 검사에 실패하면 throw 하고, 통과하면 그 뒤로 좁혀진 타입으로 진행하는 함수.
function assertIsUser(value: unknown): asserts value is User {
if (typeof value !== 'object' || value === null) {
throw new Error('Not a User');
}
const v = value as Record<string, unknown>;
if (typeof v.id !== 'string' || typeof v.name !== 'string') {
throw new Error('Not a User');
}
}
function process(value: unknown) {
assertIsUser(value);
console.log(value.name); // 여기서부터 value는 User
}if 분기로 감싸지 않아도 됩니다. 실패하면 throw, 통과하면 자동 좁힘. "이 자리부터는 무조건 User여야만 다음 줄을 실행한다" 같은 직선적인 로직에 어울려요.
asserts condition 형태도 있습니다.
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) throw new Error(message);
}
function getName(user: User | null) {
assert(user !== null, 'user는 null일 수 없음');
return user.name; // 여기서 user는 User로 좁혀짐
}assert 가 통과하면 그 뒤로 컴파일러가 user !== null 을 사실로 받아들입니다. 깊은 if 없이 코드를 직선화할 때 깔끔해요.
함정 — 함수 표현식에는 못 씀
assertion 함수에는 한 가지 제약이 있습니다 — 명시적으로 시그니처가 적힌 함수에서만 동작해요. 화살표 함수에 추론으로 두면 좁힘이 안 일어납니다.
const assert = (condition: unknown, message?: string) => {
if (!condition) throw new Error(message);
};
function getName(user: User | null) {
assert(user !== null);
return user.name; // ✗ user가 좁혀지지 않음 (User | null)
}assertion 함수는 반드시 asserts ... 시그니처를 명시한 선언된 함수 여야 합니다. 자주 만나는 함정이라 외워 두면 좋아요.
Branded types — 같은 string도 다른 타입으로
UserId 와 PostId 가 둘 다 string 이면 타입스크립트는 두 자리를 바꿔 써도 잡지 못합니다. 같은 모양이니까요.
type UserId = string;
type PostId = string;
function getUser(id: UserId): User { /* ... */ }
const postId: PostId = 'p_42';
getUser(postId); // 컴파일 통과 — 사실은 의도와 다름이 자리를 잡고 싶으면 branded types (혹은 nominal typing) 를 씁니다. 핵심은 실제로는 string이지만, 타입 단계에서만 다른 모양으로 만드는 거예요.
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };
function getUser(id: UserId): User { /* ... */ }
const a = 'u_1' as UserId;
getUser(a); // OK
const b: PostId = 'p_42' as PostId;
getUser(b); // ✗ PostId는 UserId가 아님
getUser('raw-string'); // ✗ 그냥 string도 통과 못 함string & { __brand: 'UserId' } 가 핵심입니다. 런타임에는 그냥 string이지만, 타입 단계에서는 __brand 로 다른 정체성을 갖게 돼요. 다른 brand나 그냥 string은 호환되지 않습니다.
한 단계 위 — 검증된 데이터 표현
브랜드는 단순히 ID를 구분하는 데서 끝나지 않습니다. 검증을 통과한 값 을 타입으로 표현할 수도 있어요.
type Email = string & { readonly __brand: 'Email' };
function isValidEmail(s: string): s is Email {
return /^[^@]+@[^@]+\.[^@]+$/.test(s);
}
function sendEmail(to: Email, subject: string) { /* ... */ }
const raw = '아무거나';
sendEmail(raw, '안녕'); // ✗ string은 Email이 아님
if (isValidEmail(raw)) {
sendEmail(raw, '안녕'); // OK — 가드를 통과한 자리에서만
}sendEmail 은 "검증된 이메일"을 받겠다고 시그니처에 못 박아 둡니다. 호출하는 쪽은 가드를 한 번 통과해야만 보낼 수 있어요. 검증 누락이 컴파일 단계에서 잡힘. 실무 가치가 큽니다.
한 단계 더 — Result 타입으로 에러 모델링
throw 대신 에러를 값으로 다루는 패턴. Rust 의 Result 와 비슷합니다.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
const user = (await res.json()) as User; // 실전에서는 검증 필요
return { ok: true, value: user };
} catch (e) {
return { ok: false, error: e as Error };
}
}
const r = await fetchUser('u_1');
if (r.ok) {
console.log(r.value.name);
} else {
console.error(r.error);
}discriminated union 의 강점이 그대로 살아납니다. 호출하는 쪽이 반드시 두 분기를 모두 처리하지 않으면 컴파일이 막혀요. throw/try-catch 가 의식적이지 못하게 흩어지는 코드보다 안전합니다.
이 패턴은 신호가 큽니다. 작은 함수에서는 throw가 가볍고 충분하지만, 공유되는 API 경계 에서는 Result 모델이 자주 더 안전해요.
narrowing 이 깨지는 경우들
마지막으로, narrowing이 동작하지 않는 흔한 함정 둘.
1) 비동기 사이 좁힘 잃음
async function f(value: string | null) {
if (value === null) return;
await something();
console.log(value.length); // 여기서 value는 여전히 string
}이 예제는 동작합니다. 하지만 다른 코드가 사이에 끼어들어 value를 변경할 가능성이 있을 때(클로저로 캡쳐된 경우 등) 컴파일러가 좁힘을 버릴 수 있어요. 실무에서는 좁힌 값을 별도 변수에 저장해 사용하는 게 안전합니다.
async function f(value: string | null) {
if (value === null) return;
const safe = value; // 좁혀진 string을 잡아 둠
await something();
console.log(safe.length); // 안전
}2) 메서드 호출 후 좁힘 잃음
type Box = { item?: { name: string } };
function f(box: Box) {
if (box.item === undefined) return;
doSomething();
console.log(box.item.name); // 컴파일러가 box.item을 다시 의심할 수 있음
}doSomething() 이 box를 변경했을 가능성을 컴파일러가 의식하기 시작하면, 다시 undefined 가능성이 살아납니다. 이때도 좁힌 값을 변수에 잡아두는 패턴이 가장 안전해요.
마무리
이번 글에서 정리한 내용:
- discriminated union — 같은 이름의 리터럴 필드, 또는
in으로 분기 가능 - exhaustiveness 검사 —
never변수로 새 멤버 누락 잡기 - 사용자 정의 타입 가드 —
value is X시그니처. 거짓말 위험에 주의 - assertion 함수 —
asserts value is X. 명시 선언된 함수에서만 동작 - branded types —
string & { __brand: 'UserId' }로 같은 모양 구분 - 검증을 통과한 값을 타입으로 표현 (
Email패턴) - Result 패턴 — 에러를 값으로 모델링
- narrowing이 깨지는 자리 — 좁힌 값을 변수에 잡아 두기
다음 글(#6 모듈과 .d.ts)에서는 한 모듈에 닫혀 있던 시야를 넓혀, 외부 라이브러리의 타입을 어떻게 다루고 확장하는지 — 선언 파일과 module augmentation을 다룹니다.