Discriminated Union

Yonghyun·2024년 9월 8일
0

TypeScript

목록 보기
4/4
post-thumbnail

Discriminated Union

Discriminated Union은 번역하면 구별된 유니온이라는 뜻으로 리터럴 멤버 프로퍼티가 있는 클래스가 있다면, 그 프로퍼티로 유니온 타입을 구별할 수 있다는 개념이다.

흔하게 접할 수 있는 타입 코드를 하나 살펴보자.

interface RoundIcon {
  shape: "circle";
  radius: number;
  color: string;
}

interface SquareIcon {
  shape: "square";
  width: number;
  height: number;
  color: string;
}

RoundIconSquareIcon리터럴 타입shape이라는 프로퍼티를 공통으로 가지고 있다.

이러한 경우 우리는 shape의 타입을 통해 특정 객체의 정확한 타입을 알아낼 수 있다.

type Icon = RoundIcon | SquareIcon;

const renderIcon = (icon: Icon) => {
	if (icon.shape === 'circle') {
	  // icon은 RoundIcon 타입!
	  icon.radius;
	  // icon.width <- X
	} else {
	  // Icon 타입에서 shape가 circle이 아닌 타입이므로 여기서 icon은 SquareIcon 타입!
	  icon.width
	  // icon.radius <- X
	}

여기서 shape와 같이 특정한 리터럴을 가진 객체를 식별할 수 있는 프로퍼티를 구별 프로퍼티라 표현하고, RoundIconSquareIcon을 유니온한 타입을 구별된 유니온(Discriminated Union)이라 표현한다.

Example

각각 여러 개의 타입이 동일한 이름의 리터럴 타입 프로퍼티(구별 프로퍼티)를 가지고 있다면, 그 여러 개의 타입을 구별해낼 수 있다는 점을 활용해 타입의 버전 관리를 할 수 있다.

type DTO = |
{
  version: undefined, // 버전 0
  name: string;
} | {
  version : 1,
  firstName: string;
  lastName: string;
} | {
  version : 2,
  firstName: string;
  middleName: string;
  lastName: string;
} | ...

real-world usage

이 구별된 유니온 타입을 활용하는 대표적인 예시가 Redux의 액션 객체이다.

액션 객체는 type이라는 구별 프로퍼티를 가지고 있고, switch문을 통해 이 프로퍼티를 기준으로 분기 처리를 한다. 즉, 타입이 좁혀진다.

function todosReducer(state = [], action) {
  switch (action.type) {
    case "ADD_TODO": {
      return state.concat(action.payload);
    }
    case "TOGGLE_TODO": {
      const { index } = action.payload;
      return state.map((todo, i) => {
        if (i !== index) return todo;

        return {
          ...todo,
          completed: !todo.completed,
        };
      });
    }
    case "REMOVE_TODO": {
      return state.filter((todo, i) => i !== action.payload.index);
    }
    default:
      return state;
  }
}

Exhaustive Check

interface RoundIcon {
  shape: "circle";
  radius: number;
  color: string;
}

interface SquareIcon {
  shape: "square";
  width: number;
  height: number;
  color: string;
}

export type Icon = RoundIcon | SquareIcon;
function getIconArea(icon: Icon) {
  if (icon.shape === "circle") {
    return icon.radius ** 2 * PI;
  }
  if (icon.shape === "square") {
    return icon.width * icon.height;
  }
}

위와 같이 유니온 타입의 Icon이 있고 유니온으로 묶인 각 타입들을 모두 대응하는 함수가 있는 상황이라 했을 때, 새로운 아이콘 타입을 추가하고 싶다면 IcongetIconArea를 모두 수정해 주어야 한다.

이때 Icon이 추가되었으면 getIconArea에도 조건문을 추가해 주어야 한다라는 일종의 가이드를 두고 싶은 경우, exhaustive 검사를 추가할 수 있다.

exhaustive 검사는 다음과 같이 해당 블록에서 추론한 타입이 never 타입과 호환되는 것처럼 작성하면 된다.

function getIconArea(icon: Icon) {
  if (icon.shape === "circle") {
    return icon.radius ** 2 * PI;
  }
  if (icon.shape === "square") {
    return icon.width * icon.height;
  }
  const _exhaustiveCheck: never = icon; // Here!
}

새로운 타입을 추가한 상황이라면, 마지막 타입에서 추론되는 새로운 타입은 never 타입이 아닐 것이기 때문에 never 타입에 할당할 수 없어 에러가 발생한다.

// error: 'TriangleIcon' is not assignable to 'never'

이렇게 에러를 발생시킴으로써 새로운 타입이 추가되었을 때 함께 변경되어야 하는 부분의 누락을 방지할 수 있다.

profile
Life is all about timing.

0개의 댓글