타입스크립트 기초 강좌 #4 Union / Literal / Narrowing

18 분 소요

지난 시간에는 객체 타입에 이름을 붙여 재사용하는 방법을 다뤘습니다. 이번에는 "여러 가능성 중 하나"를 표현하는 도구 — union 타입, literal 타입, narrowing을 정리합니다. 이 셋은 함께 동작해서 타입스크립트의 진짜 표현력을 보여줍니다.

Union 타입 — 여러 가능성 중 하나

값이 두세 가지 타입 중 하나일 수 있다고 표현할 때 사용합니다.

union 타입
let value: string | number = '안녕';
value = 42;          // ✓
value = true;        // 🚫 에러: boolean은 허용되지 않음

string | number는 "이 값은 string이거나 number"라는 뜻이에요. 파이프 기호(|)로 가능한 타입들을 나열합니다.

흔한 union 패턴

null이거나 객체:

값 또는 null
let user: User | null = null;
user = { id: 'u-1', name: '철수' };

여러 입력 형식 받기:

다양한 입력 받기
function parseId(id: string | number): string {
  return String(id);
}
 
parseId(42);      // ✓
parseId('u-1');   // ✓

성공 또는 에러:

결과 타입
type Result =
  | { ok: true; value: string }
  | { ok: false; error: string };
 
function fetchData(): Result {
  // ...
  return { ok: true, value: '응답 데이터' };
}

마지막 패턴은 discriminated union(태그된 유니언)이라고 부르며, 매우 자주 쓰입니다 (아래에서 더 자세히).

Literal 타입 — 정확한 값까지 표현

타입스크립트는 "string"이라는 추상적인 타입뿐 아니라 "hello"라는 정확한 값까지 타입으로 표현할 수 있습니다.

literal 타입
let direction: 'left' | 'right' | 'up' | 'down' = 'left';
direction = 'right';     // ✓
direction = 'forward';   // 🚫 에러: 4가지 중 하나만 가능

'left' | 'right' | 'up' | 'down'은 string의 부분집합 — 정확히 그 4가지 문자열 중 하나만 허용합니다. enum의 더 가벼운 대안이라고 #2에서 짚었던 게 이 기법이에요.

숫자 literal도 가능:

숫자 literal
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
const roll: DiceRoll = 4;     // ✓
const fail: DiceRoll = 7;     // 🚫

Literal + union의 강력함

literal과 union을 함께 쓰면 자바스크립트로는 표현하기 어려운 정밀한 타입이 가능합니다.

UI 상태
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
type Size = 'sm' | 'md' | 'lg';
 
function Button(props: { variant: ButtonVariant; size: Size; label: string }) {
  // ...
}
 
Button({ variant: 'primary', size: 'md', label: '확인' });   // ✓
Button({ variant: 'huge', size: 'md', label: '확인' });      // 🚫 'huge' 불가

API의 응답 status, 컴포넌트의 variant, 로딩 상태 등 유한한 선택지가 있는 모든 곳에 잘 맞아요.

const assertion — 자동으로 literal 타입 만들기

객체나 배열을 만들 때 as const를 붙이면 모든 값이 literal 타입으로 추론됩니다.

as const
const config = {
  mode: 'production',
  retries: 3,
} as const;
 
// config.mode: 'production' (string이 아닌 'production' literal)
// config.retries: 3 (number가 아닌 3 literal)
 
config.mode = 'development';  // 🚫 readonly + literal이라 변경 불가

as const로 만든 값은 readonly + 가장 좁은 타입으로 고정됩니다. 변하지 않는 설정 객체에 잘 어울려요.

배열에서도 자주 쓰입니다.

배열 as const
const colors = ['red', 'green', 'blue'] as const;
// colors: readonly ['red', 'green', 'blue']
 
type Color = typeof colors[number];
// Color = 'red' | 'green' | 'blue'

typeof colors[number]는 "colors 배열의 모든 요소 타입의 union"을 의미합니다. 이렇게 데이터 한 곳에 두고 타입을 그것에서 자동 유도하는 패턴은 실전에서 매우 자주 쓰여요. 데이터와 타입의 동기화 부담이 사라집니다.

Narrowing — 분기 안에서 타입 좁히기

union 타입의 값을 받으면 가능한 타입이 여럿이라 곧바로 모든 메소드를 호출할 수 없습니다.

union의 한계
function process(value: string | number): string {
  return value.toUpperCase();  // 🚫 number에는 toUpperCase가 없음
}

이 문제를 푸는 게 narrowing(타입 좁히기)입니다. 조건문이나 검사를 통해 타입스크립트가 "이 분기 안에서는 더 좁은 타입"이라는 걸 추론하게 만드는 거예요.

typeof로 좁히기 — 원시 타입

typeof narrowing
function process(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase();    // 여기서 value는 string으로 좁혀짐
  }
  return value.toFixed(2);          // 여기서는 number로 좁혀짐
}

typeof는 자바스크립트의 일반 키워드인데, 타입스크립트가 이를 인식해 그 분기 안에서 타입을 자동으로 좁힙니다.

'in' 연산자로 좁히기 — 객체

in narrowing
type Cat = { meow: () => void };
type Dog = { bark: () => void };
 
function makeSound(animal: Cat | Dog): void {
  if ('meow' in animal) {
    animal.meow();    // Cat으로 좁혀짐
  } else {
    animal.bark();    // Dog로 좁혀짐
  }
}

in 키워드로 객체에 특정 프로퍼티가 있는지 검사. 있으면 그 프로퍼티를 가진 타입으로 좁혀집니다.

instanceof로 좁히기 — 클래스

instanceof narrowing
function logError(error: Error | string): void {
  if (error instanceof Error) {
    console.log(error.message);     // Error로 좁혀짐
    console.log(error.stack);
  } else {
    console.log(error);              // string
  }
}

Discriminated Union — 태그로 구분

객체 union에서 가장 강력하고 자주 쓰는 패턴입니다. 공통의 "태그" 필드로 어떤 모양인지 구분합니다.

discriminated union
type Loading = { status: 'loading' };
type Success = { status: 'success'; data: string };
type Failure = { status: 'failure'; error: string };
 
type State = Loading | Success | Failure;
 
function render(state: State): string {
  switch (state.status) {
    case 'loading':
      return '로딩 중...';
    case 'success':
      return state.data;        // Success로 좁혀져서 data 접근 가능
    case 'failure':
      return state.error;        // Failure로 좁혀짐
  }
}

status 필드의 값으로 어떤 상태인지 구분합니다. switch 안 각 분기에서 타입이 자동으로 좁혀지고, 그 분기에 해당하는 필드(data, error)에 안전하게 접근할 수 있어요.

이 패턴은 비동기 상태, 폼 상태, 메시지 종류 등 여러 모양이 섞이는 모든 곳에 잘 맞습니다. 외워둘 가치가 큰 패턴이에요.

사용자 정의 type guard

복잡한 검사를 함수로 추출해 재사용할 수 있습니다.

type guard 함수
type Cat = { type: 'cat'; meow: () => void };
type Dog = { type: 'dog'; bark: () => void };
 
function isCat(animal: Cat | Dog): animal is Cat {
  return animal.type === 'cat';
}
 
function makeSound(animal: Cat | Dog): void {
  if (isCat(animal)) {
    animal.meow();    // Cat으로 좁혀짐
  } else {
    animal.bark();
  }
}

animal is Cat 부분이 핵심 — "이 함수가 true를 반환하면 매개변수가 Cat 타입이라고 컴파일러에게 알려라"는 의미입니다. type predicate라고 부릅니다.

복잡한 검사 로직을 한 군데로 추출할 수 있어 코드 재사용에 좋아요. 다만 사용자가 책임지고 검사를 정확히 작성해야 합니다 — 컴파일러는 함수 본문이 정말로 그 타입을 보장하는지까지는 확인하지 못해요.

Truthiness narrowing

자바스크립트의 truthy/falsy 검사도 narrowing 효과가 있습니다.

null 체크
function greet(name: string | null): string {
  if (name) {
    return `안녕, ${name.toUpperCase()}님`;   // string으로 좁혀짐
  }
  return '이름이 없습니다';
}
배열 비어있음 체크
function first<T>(arr: T[] | undefined): T | undefined {
  if (arr && arr.length > 0) {
    return arr[0];     // T[]로 좁혀짐
  }
  return undefined;
}

이 단순한 if 검사가 자연스럽게 narrowing으로 동작합니다.

never로 exhaustiveness 검사

discriminated union의 모든 case를 처리했는지 컴파일이 보장하게 만들 수 있습니다.

exhaustiveness check
type State = Loading | Success | Failure;
 
function render(state: State): string {
  switch (state.status) {
    case 'loading':
      return '로딩 중...';
    case 'success':
      return state.data;
    case 'failure':
      return state.error;
    default:
      const _exhaustive: never = state;  // 모든 case를 다뤘다면 여긴 도달 불가
      return _exhaustive;
  }
}

만약 미래에 State에 새 종류가 추가됐는데 switch에 case를 안 추가하면, default 분기에서 그 새 타입이 never가 아니라 실제 타입이 되어 컴파일 에러가 발생합니다. 빠뜨림을 컴파일 시점에 잡아주는 강력한 패턴이에요.

처음에는 어색할 수 있는데, 큰 코드베이스에서 진가가 발휘됩니다. 외울 정도까지는 아니지만 "아, 이런 게 가능하구나" 정도로 알아두세요.

자주 쓰는 nullable 패턴

T | null 또는 T | undefined 모양은 너무 자주 등장해서 별도 별명을 두는 팀도 많습니다.

nullable 별명
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
 
let user: Nullable<User> = null;
let result: Optional<string> = undefined;

Nullable<T>는 #6에서 다룰 제네릭의 미리 보기입니다. T 자리에 어떤 타입이든 들어올 수 있어요.

직접 해보기 — 비동기 상태

리액트나 일반 비동기 코드에 자주 등장하는 패턴을 타입으로 표현해봅시다.

async-state.ts
// 데이터 페칭의 모든 가능한 상태
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };
 
// 사용자 정보 페칭 시뮬레이션
type User = { id: string; name: string };
 
let state: FetchState<User> = { status: 'idle' };
 
function startFetch(): void {
  state = { status: 'loading' };
}
 
function onSuccess(user: User): void {
  state = { status: 'success', data: user };
}
 
function onError(message: string): void {
  state = { status: 'error', error: message };
}
 
function describeState(s: FetchState<User>): string {
  switch (s.status) {
    case 'idle':
      return '아직 시작 안 함';
    case 'loading':
      return '불러오는 중...';
    case 'success':
      return `안녕하세요, ${s.data.name}님!`;
    case 'error':
      return `에러: ${s.error}`;
  }
}
 
startFetch();
console.log(describeState(state));  // 불러오는 중...
 
onSuccess({ id: 'u-1', name: '철수' });
console.log(describeState(state));  // 안녕하세요, 철수님!

각 상태마다 정확히 그 상태에서 의미 있는 필드만 접근 가능합니다. idle이나 loading에서는 dataerror도 없고, success에서는 data만, error에서는 error만. 잘못된 필드 접근이 컴파일 시점에 막히는 것 — 이게 discriminated union의 진가입니다.

흔한 함정

1. 타입 좁히기 후에 다시 넓어짐

좁히기가 풀리는 경우
function process(items: (string | number)[]): void {
  items.forEach(item => {
    if (typeof item === 'string') {
      // 여기서는 string으로 좁혀짐
      setTimeout(() => {
        item.toUpperCase();   // 🚫 콜백 안에서는 다시 string | number로 넓어질 수 있음
      });
    }
  });
}

콜백/클로저로 들어가면 좁히기가 풀릴 수 있습니다. 좁힌 값을 변수에 담아 사용하는 식으로 우회할 수 있어요.

우회
if (typeof item === 'string') {
  const s = item;            // 좁힌 결과를 변수에 보관
  setTimeout(() => {
    s.toUpperCase();         // ✓
  });
}

2. literal union 작성 시 따옴표 누락

실수
type Color = 'red' | 'green' | blue;  // 🚫 'blue'가 아닌 식별자로 해석됨

literal은 따옴표 안의 값입니다. 따옴표 빼면 식별자(변수)가 되어 다른 의미가 돼요. 처음에 자주 하는 실수입니다.

3. 너무 좁은 literal 타입

과도한 literal
function setMode(mode: 'dev' | 'prod'): void { /* ... */ }
 
const mode = 'dev';      // 타입 추론 결과: 'dev'? 아니면 string?
setMode(mode);           // 경우에 따라 에러 가능

const mode = 'dev'의 추론은 보통 literal 타입('dev')이 되지만, let mode = 'dev'string이 됩니다. 필요하면 as const나 명시적 타입 어노테이션을 써서 의도를 분명히 해야 해요.

마무리

이번 글에서는 "여러 가능성 중 하나"를 다루는 도구들을 정리했습니다.

  • union (A | B) — 여러 타입 중 하나
  • literal 타입 ('red' | 'blue') — 정확한 값을 타입으로
  • as const — 자동으로 literal + readonly로 만들기
  • narrowingtypeof, in, instanceof, discriminated union, 사용자 type guard로 분기 안에서 타입 좁히기
  • discriminated union — 객체 union의 정석 패턴 (status 같은 태그 필드 + switch)
  • never — exhaustiveness 검사

다음 글인 "타입스크립트 기초 강좌 #5 함수 타입"에서는 함수의 타입을 더 정밀하게 표현하는 방법 — 옵셔널/디폴트 인자, 함수 오버로드, 그리고 제네릭 입문을 다루겠습니다. 제네릭은 다음다음 #6에서 더 깊이 들어가니, #5에서는 가벼운 첫 만남 정도로 시작합니다.