타입스크립트 심화 #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 }
  }
}

okdiscriminator 입니다. 이 자리에 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 에서 처리를 빠뜨리면? 컴파일이 잡아 주게 만들 수 있습니다.

exhaustiveness 검사
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 하고, 통과하면 그 뒤로 좁혀진 타입으로 진행하는 함수.

assertion 함수
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 형태도 있습니다.

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도 다른 타입으로

UserIdPostId 가 둘 다 string 이면 타입스크립트는 두 자리를 바꿔 써도 잡지 못합니다. 같은 모양이니까요.

이건 잡히지 않음
type UserId = string;
type PostId = string;
 
function getUser(id: UserId): User { /* ... */ }
 
const postId: PostId = 'p_42';
getUser(postId);     // 컴파일 통과 — 사실은 의도와 다름

이 자리를 잡고 싶으면 branded types (혹은 nominal typing) 를 씁니다. 핵심은 실제로는 string이지만, 타입 단계에서만 다른 모양으로 만드는 거예요.

brand 패턴
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 와 비슷합니다.

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) 비동기 사이 좁힘 잃음

await 사이 좁힘이 풀림
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을 다룹니다.