상호 배타적인 유니온 타입 다루기


타입스크립트를 사용하다보면 두 가지 타입 중 하나를 선택해야 하는 경우가 종종 있다. 예를 들어 API에서 내려주는 데이터가 아래 두 가지 형태가 있다고 해보자.

{
  "data": {
    "name": "John",
    "age": 30
  }
}
{
  "error": {
    "message": "Not Found"
  }
}

이럴 때 Response의 타입을 정의할 때 dataerror 중 하나만 가질 수 있도록 정의하고 싶다면 어떻게 해야 할까? 유니온 타입을 사용하면 될 것 같다.

type ResponseData = {
  data: {
    name: string;
    age: number;
  };
};

type ResponseError = {
  error: {
    message: string;
  };
};

type Res = ResponseData | ResponseError;

하지만 위 코드는 문제가 있다. response는 data를 가지고 있는 경우 error는 가지고 있지 않아야 하고, 반대로 error를 가지고 있는 경우 data는 가지고 있지 않아야 한다. 하지만 위 코드는 data와 error가 동시에 존재할 수 있는 문제가 있다.

// 에러가 발생하지 않는다.
const response: Res = {
  data: {
    name: 'John',
    age: 30,
  },
  error: {
    message: 'Not Found',
  },
};

이 문제를 해결하려면 ResponseDataResponseError를 상호 배타적인 유니온 타입으로 정의해야 한다. 즉, ResponseDataResponseError가 동시에 존재할 수 없도록 해야 한다.

type ResponseData = {
  data: {
    name: string;
    age: number;
  };
  error?: never;
};

type ResponseError = {
  error: {
    message: string;
  };
  data?: never;
};

type Res = ResponseData | ResponseError;

이렇게 작성하면 ResponseDataResponseError가 상호 배타적인 유니온 타입이 되어 dataerror가 동시에 존재할 수 없게 된다.

// 에러가 발생한다.
const response: Res = {
  data: {
    name: 'John',
    age: 30,
  },
  error: {
    message: 'Not Found',
  },
};

XOR 연산자

위 문제를 일반화하면 두 가지 타입 중 하나만 가질 수 있는 상호 배타적인 유니온 타입을 만들어야 한다. 집합적인 관점으로 보면 대칭차집합을 구하는 것과 같다. 이를 위해 XOR 연산자를 정의할 수 있다.

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };

type XOR<T, U> = T | U extends object
  ? (Without<T, U> & U) | (Without<U, T> & T)
  : T | U;

이를 위 코드에 적용하면 동일한 결과를 얻을 수 있다.

type ResponseData = {
  data: {
    name: string;
    age: number;
  };
};

type ResponseError = {
  error: {
    message: string;
  };
};

type Res = XOR<ResponseData, ResponseError>;

이제 ResResponseDataResponseError가 상호 배타적인 유니온 타입이 되어 dataerror가 동시에 존재할 수 없게 된다.

// 에러가 발생한다.
const response: Res = {
  data: {
    name: 'John',
    age: 30,
  },
  error: {
    message: 'Not Found',
  },
};