유니온 타입을 안전하게 좁히는 판별 필드 패턴

유니온 타입을 안전하게 좁히는 판별 필드 패턴

한눈에 보기

각 타입에 kind 같은 리터럴 필드를 넣으면 switch 문이 값과 타입을 동시에 좁힌다. 필드 이름과 값은 도메인 상태를 드러내도록 고른다.

예시 코드 안내

본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.

목차

왜 이 문제가 생기는가

string 또는 number 같은 유니온 타입을 받으면 공통 멤버만 사용할 수 있다. 타입마다 분기 동작이 다르면 런타임 값으로 타입을 안전하게 좁힐 기준이 필요하다.

선택적 필드만으로 상태를 표현할 때의 문제

비동기 요청 상태를 하나의 타입과 선택적 필드로 표현하면 존재할 수 없는 조합까지 허용된다.

type RequestState<Data> = {
  loading: boolean;
  data?: Data;
  error?: Error;
};

이 타입에서는 아래 상태가 모두 가능하다.

const impossible: RequestState<string[]> = {
  loading: true,
  data: ["already finished"],
  error: new Error("failed at the same time"),
};

로딩 중인데 데이터와 오류가 동시에 있는 상태가 정말 필요한지 불분명하다. UI 곳곳에서 loading, data, error 조합을 다시 해석해야 한다.

판별 유니온으로 상태별 필드를 분리하면 불가능한 조합을 만들 수 없다.

type RequestState<Data> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: Data }
  | { status: "failure"; error: Error };
function UserList({ state }: { state: RequestState<User[]> }) {
  switch (state.status) {
    case "idle":
      return <button>불러오기</button>;
    case "loading":
      return <Spinner />;
    case "success":
      return <List users={state.data} />;
    case "failure":
      return <ErrorPanel message={state.error.message} />;
  }
}

각 분기 안에서는 해당 상태가 가진 필드만 보인다. loading 분기에서 실수로 state.data를 읽으면 컴파일 오류가 난다.

불가능한 상태를 타입에서 제거하기

결제 수단처럼 형태가 다른 값에도 같은 원칙을 적용할 수 있다.

type PaymentMethod =
  | {
      type: "card";
      cardToken: string;
      installmentMonths: number;
    }
  | {
      type: "bank-transfer";
      bankCode: string;
      accountHolder: string;
    }
  | {
      type: "credit";
      creditAmount: number;
    };

모든 필드를 선택적으로 가진 하나의 객체보다 각 결제 수단에 필요한 값이 분명하다. API 스키마와 폼 검증도 같은 구조로 맞출 수 있다.

판별 필드 이름 어울리는 의미
type, kind 서로 다른 데이터 종류
status, state 시간에 따라 변하는 상태
event 상태를 바꾸는 입력
provider 외부 공급자별 형태

값은 자유 문자열보다 리터럴 유니온으로 좁혀야 TypeScript가 분기와 필드를 연결할 수 있다.

상태 전이와 판별 유니온

상태 타입을 만들었다면 어떤 이벤트가 어떤 상태를 만드는지도 표현할 수 있다.

type RequestEvent<Data> =
  | { type: "request" }
  | { type: "resolve"; data: Data }
  | { type: "reject"; error: Error }
  | { type: "reset" };

function reduceRequest<Data>(
  state: RequestState<Data>,
  event: RequestEvent<Data>,
): RequestState<Data> {
  switch (event.type) {
    case "request":
      return { status: "loading" };
    case "resolve":
      return { status: "success", data: event.data };
    case "reject":
      return { status: "failure", error: event.error };
    case "reset":
      return { status: "idle" };
  }
}
stateDiagram-v2
    [*] --> idle
    idle --> loading: request
    loading --> success: resolve
    loading --> failure: reject
    success --> loading: request
    failure --> loading: request
    success --> idle: reset
    failure --> idle: reset

다만 이 reducer는 idle에서 바로 resolve 이벤트가 와도 success로 바꾼다. 상태마다 허용 이벤트를 엄격히 제한해야 한다면 런타임 조건이나 더 구체적인 상태 머신이 필요하다. 타입만으로 모든 시간적 규칙이 자동 보장되지는 않는다.

외부 데이터는 먼저 검증해야 한다

서버가 { status: "success" }처럼 필수 data 없이 응답해도 TypeScript 타입 선언은 런타임 값을 고치지 않는다.

function parseRequestState(value: unknown): RequestState<User[]> {
  if (typeof value !== "object" || value === null) {
    throw new Error("state must be an object");
  }

  const row = value as Record<string, unknown>;

  switch (row.status) {
    case "idle":
    case "loading":
      return { status: row.status };
    case "success":
      if (!Array.isArray(row.data)) throw new Error("success requires data");
      return { status: "success", data: parseUsers(row.data) };
    case "failure":
      if (typeof row.message !== "string") {
        throw new Error("failure requires message");
      }
      return { status: "failure", error: new Error(row.message) };
    default:
      throw new Error("unknown request state");
  }
}
판별 필드는 공개 계약이다

서버와 앱이 다른 배포 주기를 가진다면 새로운 status를 추가했을 때 구버전 클라이언트가 어떻게 처리할지 정해야 한다. unknown 상태 fallback과 API 버전 정책을 함께 생각한다.

결론

판별 유니온은 kindstatus 같은 명시적인 리터럴 필드로 각 상태가 가질 수 있는 데이터 조합을 고정한다. 선택적 필드 여러 개로 불가능한 상태를 허용하기보다 상태별 타입과 전이를 드러낸다. 다만 외부 JSON은 타입 선언만으로 안전해지지 않으므로 경계에서 판별 값과 필수 필드를 검증한 뒤 내부 유니온으로 변환해야 한다.

관련 노트