Geuni

타입가드와 구조 분해 할당의 미묘한 함정

문제상황

최근 셀렉트박스의 선택에 따라 다른 데이터를 요청해야하는 경우가 발생했다.
각각의 다른 스키마를 가진 데이터를 테이블 컬럼에 맞게 전처리하는 함수가 필요했고, 분기처리를 해주었다.

과정에서 유니온타입을 생성했는데, 간단한 예시를 만들어보면 다음과 같다.
언듯보기에 문제없어보이지만, 컴파일 단계에서 타입에러가 발생했다.

interface SingleData {
  type: "single";
  items: string[];
}

interface GroupData {
  type: "group";
  items: number[];
}

type DataProps = SingleData | GroupData;

function isSingleData(props: DataProps): props is SingleData {
  return props.type === "single";
}

function isGroupData(props: DataProps): props is GroupData {
  return props.type === "group";
}

function processData({ items, type }: DataProps): string {
  if (isSingleData({ items, type })) {
    /**
     * '{ items: string[] | number[]; type: "single" | "group"; }' 형식의 인수는 'DataProps' 형식의 매개 변수에 할당될 수 없습니다.
     * '{ items: string[] | number[]; type: "single" | "group"; }' 형식은 'GroupData' 형식에 할당할 수 없습니다.
     * 'type' 속성의 형식이 호환되지 않습니다.
     * '"single" | "group"' 형식은 '"group"' 형식에 할당할 수 없습니다.
     * '"single"' 형식은 '"group"' 형식에 할당할 수 없습니다.ts(2345)
     */
    return items.join(", ");
  }

  if (isGroupData({ items, type })) {
    /**
     * '{ items: string[] | number[]; type: "single" | "group"; }' 형식의 인수는 'DataProps' 형식의 매개 변수에 할당될 수 없습니다.
     * '{ items: string[] | number[]; type: "single" | "group"; }' 형식은 'GroupData' 형식에 할당할 수 없습니다.
     * 'type' 속성의 형식이 호환되지 않습니다.
     * '"single" | "group"' 형식은 '"group"' 형식에 할당할 수 없습니다.
     * '"single"' 형식은 '"group"' 형식에 할당할 수 없습니다.ts(2345)
     */
    return items.reduce((a, b) => a + b, 0).toString();
  }

  throw new Error("Invalid type");
}

원인

isSingleDataisGroupData 함수는 DataProps 타입의 객체를 받아 해당 객체의 타입을 좁혀준다.
하지만, processData 함수 내에서 호출할 때,

if (isSingleData({ items, type })) {
  // ...
}

이 코드에서는 {items, type}은 원본 객체가 아닌 새롭게 생성된 객체이다.
즉, 새로 만든 객체의 type 속성은 원래 객체에서 분해한 값이지만, TypeScript는 이 새 객체의 type이 정확히 어떤 값인지 추적하지 못하고, 가능한 모든 값인 "single" | "group"으로 타입을 추론한다.

따라서 새 객체 { items, type }이 DataProps 타입으로 받아들여질 수는 있지만, 그 안의 type 속성은 좀 더 넓은 타입 "single" | "group"으로 인식되어, 타입 가드가 의도한 대로 작동하지 않는다.

해결방법

해결방법은 의외로 간단하다.
구조분해할당으로 넘기지 않으면 된다.

function isSingleData(type: DataProps): type is SingleData {
  return type.type === "single";
}

function isGroupData(type: DataProps): type is GroupData {
  return type.type === "group";
}

function processData(props: DataProps): string {
  if (isSingleData(props)) {
    return props.items.join(", ");
  }

  if (isGroupData(props)) {
    return props.items.reduce((a, b) => a + b, 0).toString();
  }

  throw new Error("Invalid type");
}

구조분해할당을 계속 유지하고 싶다면 다음과 같이 작성해도 된다.

// ** TypeScript 4.6 이상 권장
// remove isSingleData, isGroupData
function processData({ items, type }: DataProps): string {
  if (type === "single") {
    return items.join(", ");
  }

  if (type === "group") {
    return items.reduce((a, b) => a + b, 0).toString();
  }

  throw new Error("Invalid type");
}

참고자료

Control flow analysis for destructured discriminated unions
React Query and TypeScript

타입가드와 구조 분해 할당의 미묘한 함정 | geuni