본문으로 건너뛰기
김신건의 로그

[TypeScript] Union & Intersection Types

· 수정 · 📖 약 2분 · 635자/단어 #typescript #types #union #intersection
TypeScript Union Type, TypeScript Intersection Type, TS Union, TS Intersection, discriminated union, tagged union, TypeScript 유니온, TypeScript 인터섹션

정의

  • Union Type (A | B): A 이거나 B (또는 둘 다) 인 값
  • Intersection Type (A & B): A 이면서 B 인 값

두 조합은 TypeScript 의 타입 시스템에서 가장 자주 쓰이는 도구입니다.

Union Type

let id: string | number = "abc";
id = 42;
id = true;    // 오류

function print(v: string | number): void {
  console.log(v);
}

Union 사용 시 narrowing 필요

function print(v: string | number): void {
  v.toUpperCase();          // 오류: number 에는 없음

  if (typeof v === "string") {
    v.toUpperCase();        // OK: string 으로 좁혀짐
  } else {
    v.toFixed(2);           // OK: number
  }
}

자세한 것은 Type Narrowing 참조.

Union 의 공통 부분

여러 타입의 공통 필드/메서드만 union 그 자체에서 접근 가능:

type Cat = {name: string; meow(): void};
type Dog = {name: string; bark(): void};

function greet(pet: Cat | Dog): void {
  console.log(pet.name);   // OK: 둘 다 name 가짐
  pet.meow();               // 오류: Dog 에는 meow 없음
}

Discriminated Union (Tagged Union)

Union 의 각 case 에 공통 리터럴 필드 (discriminant) 를 두어 TypeScript 가 case 별로 정확히 좁힘:

type Shape =
  | {kind: "circle"; radius: number}
  | {kind: "square"; side: number}
  | {kind: "rectangle"; width: number; height: number};

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ** 2;       // s: {kind: "circle", radius: number}
    case "square":
      return s.side ** 2;                    // s: {kind: "square", side: number}
    case "rectangle":
      return s.width * s.height;             // s: {kind: "rectangle", ...}
  }
}

Exhaustive check (never)

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
    case "rectangle": return s.width * s.height;
    default:
      const _exhaustive: never = s;
      return _exhaustive;
  }
}

// 새 case 추가 시
type Shape = ... | {kind: "triangle"; base: number; height: number};

// _exhaustive: never = s  ->  s 가 triangle 이라 never 에 할당 불가 = 컴파일 오류

새 case 를 놓치지 않게 하는 강력한 패턴.

Result / Either

type Result<T, E = Error> =
  | {ok: true; value: T}
  | {ok: false; error: E};

async function fetchUser(id: number): Promise<Result<User>> {
  try {
    const user = await api.getUser(id);
    return {ok: true, value: user};
  } catch (e) {
    return {ok: false, error: e as Error};
  }
}

const r = await fetchUser(1);
if (r.ok) {
  console.log(r.value.name);   // r: {ok: true, value: User}
} else {
  console.error(r.error);       // r: {ok: false, error: Error}
}

Rust 의 Result 패턴을 그대로.

Intersection Type

type Named = {name: string};
type Aged = {age: number};

type Person = Named & Aged;

const p: Person = {name: "kim", age: 30};

여러 interface 병합

interface HasId {id: number}
interface HasCreatedAt {createdAt: Date}
interface HasName {name: string}

type Entity = HasId & HasCreatedAt & HasName;

Mixin 스타일

type Loggable = {log(): void};
type Cacheable = {cache(): void};

class Service implements Loggable & Cacheable {
  log(): void { ... }
  cache(): void { ... }
}

Intersection with union

type A = {x: number} | {y: number};
type B = {z: number};

type C = A & B;
// = ({x: number} & {z: number}) | ({y: number} & {z: number})
// = {x: number, z: number} | {y: number, z: number}

Distributive.

함정: 충돌하는 필드

type A = {value: string};
type B = {value: number};

type C = A & B;    // C.value: string & number = never
const c: C = {value: "hi"};    // 오류 (never 에 할당 불가)

같은 필드 이름에 호환 안 되는 타입이 있으면 never.

Union vs Intersection 의미론

Union (|):

  • 값 관점: A 값들의 집합 ∪ B 값들의 집합
  • 타입 관점: A 이거나 B (알려진 것보다 적은 정보)
  • 접근: 공통 필드만 안전

Intersection (&):

  • 값 관점: A 값들의 집합 ∩ B 값들의 집합
  • 타입 관점: A 이면서 B (더 많은 정보)
  • 접근: A 의 모든 것 + B 의 모든 것

직관 정리:

  • 개인 = 학생 ∨ 회사원 (union)
  • 학생회장 = 학생 ∧ 리더 (intersection)

함수 매개변수의 union / intersection

Contravariance: 매개변수는 반전됨.

type F1 = (x: string) => void;
type F2 = (x: number) => void;

type Both1 = F1 | F2;    // (x: string) => void  |  (x: number) => void
type Both2 = F1 & F2;    // (x: string & number) => void = (x: never) => void

& 로 조합된 함수는 더 넓은 인자 를 받아야 함 (실제 호출 가능한 값이 좁아짐).

리턴 타입은 covariance:

type G1 = () => {a: number};
type G2 = () => {b: string};

type Return1 = G1 | G2;      // () => {a: number} | () => {b: string}
type Return2 = G1 & G2;      // () => {a: number} & {b: string} = {a, b}

Nullable 패턴

type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type Maybe<T> = T | null | undefined;
function parseId(s: string): Maybe<number> {
  const n = parseInt(s, 10);
  return isNaN(n) ? null : n;
}

keyof union

keyof T 는 T 의 필드 이름 union:

type User = {id: number; name: string; email: string};
type UserKeys = keyof User;    // "id" | "name" | "email"

function get<K extends UserKeys>(user: User, key: K): User[K] {
  return user[key];
}

실전: State machine

type State =
  | {status: "idle"}
  | {status: "loading"; requestId: string}
  | {status: "success"; data: string}
  | {status: "error"; error: Error};

function reducer(state: State, action: Action): State {
  switch (state.status) {
    case "idle":
      if (action.type === "fetch") {
        return {status: "loading", requestId: action.id};
      }
      return state;
    case "loading":
      if (action.type === "success") {
        return {status: "success", data: action.data};
      }
      if (action.type === "fail") {
        return {status: "error", error: action.error};
      }
      return state;
    // ...
  }
}

각 status 별로 접근 가능한 필드가 자동으로 좁혀짐.

실전: Options 병합

type BaseOptions = {name: string};
type WithTimeout = {timeout: number};
type WithRetry = {retry: number; retryDelay: number};

type Options =
  | BaseOptions
  | (BaseOptions & WithTimeout)
  | (BaseOptions & WithRetry)
  | (BaseOptions & WithTimeout & WithRetry);

// 또는 단순화
type Options = BaseOptions & Partial<WithTimeout> & Partial<WithRetry>;

함정

WARNING

Discriminant 없는 union 은 좁히기 어려움. A | B 두 shape 이 겹치면 typeof 로 안 됨. Discriminant field (kind, type) 추가가 관용.

CAUTION

{value: string} & {value: number} = never. 호환 안 되는 필드 병합은 never. 실수로 발생하면 조용히 오류.

WARNING

Union 분배 (extends). T extends U ? X : Y 에서 T 가 union 이면 자동 분배. 원치 않으면 [T] extends [U] 로 tuple.

IMPORTANT

Function overload 대신 union. 매개변수가 여러 타입이면 union 시그니처 하나로 표현 가능한 경우 많음.

CAUTION

Intersection 은 나열 순서와 무관. A & B = B & A. 필드 충돌 시 우측이 이긴다고 오해 금물.

관련 위키

이 글의 용어 (8개)
[TypeScript] Conditional Typestypescript
정의 Conditional Type 은 형태로 타입 레벨에서 조건 분기를 표현합니다. 키워드로 매칭된 타입을 추출하고, union 에 대해서는 자동 분배 (distributive…
[TypeScript] Genericstypescript
정의 Generics 는 타입을 파라미터화하는 문법입니다. 함수, 클래스, 인터페이스, type alias 가 재사용 가능 하면서도 타입 안전 하도록 만듭니다. Java 의 ge…
[TypeScript] Interfacestypescript
정의 Interface 는 객체의 shape (형태) 을 정의하는 TypeScript 문법입니다. 필드, 메서드, 인덱스 시그니처, 호출 시그니처를 선언하며, 로 상속하고 dec…
[TypeScript] Mapped Typestypescript
정의 Mapped Type 은 기존 타입의 각 필드를 순회 ( ) 하며 새 타입을 생성하는 문법입니다. , modifier 를 붙이거나 제거 ( , ) 하고, clause 로 키…
[TypeScript] Type Aliasestypescript
정의 Type Alias 는 타입에 이름을 붙이는 TypeScript 문법 ( ). Interface 는 객체 shape 만 표현하지만, type alias 는 모든 타입 표현식…
[TypeScript] Type Narrowing (Type Guards)typescript
정의 Type Narrowing 은 TypeScript 컴파일러가 제어 흐름 분석 (control flow analysis) 을 통해 특정 위치에서 값의 타입을 더 좁게 추론하는…
[TypeScript] Utility Typestypescript
정의 Utility Types 는 TypeScript 표준 라이브러리에 포함된 미리 정의된 generic 타입 들입니다. 기존 타입을 변형/구성하는 흔한 패턴을 제공하며, map…
TypeScripttypescript
정의 TypeScript 는 Microsoft 가 2012년 발표한 JavaScript 의 상위 집합 프로그래밍 언어입니다. 정적 타입 시스템, 인터페이스, 제네릭, enum, …

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기