Type Guard 잘 써먹기

Einere·2023년 1월 12일
post-thumbnail

발단

회사에서 특정 파일의 업로드와, 업로드 성공 여부에 따라 알림 전송 가능 여부를 결정해야 하는 기능을 구현해야 했다.
우선, 업무 규칙만 모아놓은 커스텀 훅을 구현했다.

커스텀 훅

function useFooBar() {
  // 초기값은 null로 설정!
  const [fooResult, setFooResult] = useState<AxiosResponse | null>(null);
  const [barResult, setBarResult] = useState<AxiosResponse | null>(null);

  // 각 axios 결과가 200 이어야 한다.
  const isUploadingFooSuccess = fooResult?.status === 200;
  const isUploadingBarSuccess = barResult?.status === 200;

  // BE 에 notify 요청을 보낼 수 있는 조건
  const isCanNotify = isUploadingFooSuccess && isUploadingBarSuccess;

  return {
    // ...
    isCanNotify
  };
}

useFooBar 커스텀 훅에서는 nullable 상태인 fooResultbarResult 를 가지고 있으며, 이 두 값 모두 status 속성이 200 이라면 notify 를 할 수 있다.

컴포턴트

function UploadFooBar() {
  const {
    isCanNotify,
  } = useFooBar();

  const uploadFooBarForPreProcess = () => {
    // isCanNotify 를 이용해 early return 적용!
    if (!isCanNotify) {
      showErrorToast(new Error('전처리에 필요한 파일이 정상적으로 업로드되지 않았습니다. 다시 업로드 해주세요.'));
      return;
    }

    // 이 시점부터 fooResult 와 barResult 는 non-null 이다.
    Promise.all([
      notifyFoo(fooResult.config.data.name), // TS2531: Object is possibly 'null'.
      notifyBar(barResult.config.data.name), // TS2531: Object is possibly 'null'.
    ])
      // ...
  };

  return (
    <form onSubmit={uploadFooBarForPreProcess}>
      /* ... */
    </form>
  );
}

isCanNotify 를 통해 개발자는 fooResultbarResultnull 이 아님을 알고 있지만, 타입가드를 해주지 않았기 때문에, TS는 그 사실을 알지 못해서 타입 에러가 발생!

왜 mutiple type guard 는 미지원일까...

TS에게 이러한 값들이 non-nullable 타입임을 알려주기 위해서는 다양한 type narrowing 기술을 사용해야 한다.
그 중에서 나는 type guard를 애용하는 편이다. (커스터마이징이 편해서..)

그런데 문제는 지금 타입 가드를 해야 할 변수가 2개 이상이라는 점이다. 현재 TS 에서는 multiple argument 에 대해 type guard 를 지원하지 않기 때문에, 편법을 써야 한다.

참고 링크 무려 2018년부터 요구사항이 있었지만 대응이 없는.. 🫠

인자를 단일화해서 해결!

결국 구글링해서, 배열을 이용해 단일 인자화 한 뒤, 타입 가드하는 방법을 찾게 되었다.

function useFooBar() {
  // 두 상태 모두 사용할 수 있는 범용 타입 가드 함수 구현
  function isUploadingFooBarSuccess<T extends AxiosResponse>(
    fooBarResult: T | null,
  ): fooBarResult is Exclude<T, null> {
    return fooBarResult?.status === 200;
  }

  // T | null 타입의 튜플을 받는 타입 가드 함수로 변경.
  // 만약 타입 가드를 통과한다면, 인자가 [T, T] 타입임을 보장한다.
  function isCanNotify<T extends AxiosResponse>(args: [T | null, T | null]): args is [T, T] {
    return args.every(isUploadingFooBarSuccess);
  }
  
  // ...
}

기존의 단순 boolean 변수 대신에 isUploadingFooBarSuccess 라는 범용 함수를 구현하고, isCanNotify 또한 타입 가드 함수로 바꿨다.

여기서, args[T | null, T | null] 이라는 튜플 타입으로 정의하고, 반환 시그니처를 args is [T, T] 로 설정하여 반환된 튜플의 원소가 모두 T 임을 보장하게 했다.

이제 수정된 코드를 실제 로직에 적용해보자.

function UploadFooBar() {
  const uploadFooBarForPreProcess = () => {
    // isCanNotify 타입 가드의 인자로 넣기 위한 변수를 선언 및 할당
    const uploadingFooBarList: [AxiosResponse | null, AxiosResponse | null] = [
      fooResult,
      barResult,
    ];

    // 타입 가드!
    if (!isCanNotify(uploadingFooBarList)) {
      showErrorToast(new Error('전처리에 필요한 파일이 정상적으로 업로드되지 않았습니다. 다시 업로드 해주세요.'));
      return;
    }

    // 타입 에러가 발생하지 않는다!
    Promise.all([
      notifyFoo(uploadingFooBarList[0].config.data.name),
      notifyBar(uploadingFooBarList[1].config.data.name),
    ])
      // ...
  };
  
  // ...
}

개선된 코드에서는 uploadingFooBarList 라는 튜플 변수를 만든 뒤, isCanNotify 를 통해 타입 가드를 처리한다.

그러면 early return 밑의 영역에서는 uploadingFooBarList 의 타입이 [AxiosResponse, AxiosResponse] 로 추론되어, 인덱스를 통해 원소를 뽑아오면 타입 에러가 발생하지 않는다.

profile
웹 기술로 문제를 해결하는, 지속가능한 엔지니어를 지향합니다.

0개의 댓글