개발/기타

TypeScript Deep-Dive: 부분 타입 추론

JonghwanWon 2023. 9. 6. 22:01

TypeScript의 꽃🌷이라 할 수 있는 타입추론(Type Inference)과 Generic Type의 부분 타입추론 문제에 대해 알아봅니다.

이 주제에 대해 관심이 생긴다면 꼭 끝까지 파헤쳐 개념을 이해하시길 바랍니다.

이 글은 타입스크립트를 활용해 보았으며, Generic Type에 대한 기본적인 이해가 있음을 바탕으로 합니다.


TL;DR

TypeScript Generic은 완벽하지 않습니다. 조금 더 자세히 말해보자면 Generic의 부분적인 추론은 완벽하지 않습니다.

TypeScript는 모든 Generic에 대해서 추론해 낼 수 있지만, 부분적인 추론에 대해서는 지원하지 않습니다. (적어도 현재까지는)

부분 추론 타입이 제한되는 상황에서 Generic Function에 대해 정상적인 추론을 위해 몇 가지 방안이 존재합니다.

TypeScript Playground


예시로 등장하는 custom swr hook의 부분타입 추론 문제는 swr@2.1.0에서 발생하였고, swr@2.1.1에서 이 문제를 고려해 타입이 변경되었습니다.

현재 회사에서는 서버 상태관리도구로 swr을 채택하여 활용중이고, 재사용을 고려한 custom swr hook을 만들어 활용 중에 있습니다.

대략적인 모습은 아래와 같습니다.

export type UseRequestOption<Data> = SWRConfiguration<Data>;

export function useRequest<Data>(endpoint: string, options?: UseRequestOption<Data>) {
  return useSWR(
    endpoint,
    async url => {
      const resp = await axiosInstance.get<Data>(url);
      return resp.data;
    },
    options,
  );
}

이를 활용해, 재사용 가능하도록 커스텀 훅을 만들어 활용하게 됩니다.

interface Post {
  title: string;
}

interface UsePostsReturn {
  posts: Post[];
}

export function usePosts(option?: UseRequestOption<UsePostsReturn>) {
  return useRequest<UsePostsReturn>('/api/posts', option);
}

실제 사용할 때에는 이런 모습이 됩니다. 사용처에서 swr option을 활용하여 각각의 처리가 가능하도록 합니다.

이 상황에서 부분 타입추론의 문제가 발생합니다.

const Component = () => {
  const { data } = usePosts();
  // ^? data: UsePostsReturn
};

swr의 반환값 중 data는 undefined가 될 수 있는 상황이 존재합니다. 즉 캐시된 데이터가 없는 상황입니다.

swr은 suspense나 fallbackData 옵션을 주어 BlockingData(NotNullable) 타입으로 만들 수 있지만, 주지 않았음에도 BlockingData로 처리되었습니다. 원인을 살펴봅시다.

 

TypeScript Generic은 완벽하지 않습니다. 조금 더 자세히 말해보자면 Generic의 부분적인 추론은 완벽하지 않습니다.

TypeScript는 모든 Generic에 대해서 추론해 낼 수 있지만, 부분적인 추론에 대해서는 지원하지 않습니다. (적어도 현재까지는)

type Foo = <A = any, B = any, C = any>(a: A, b: B, c: C) => [A, B, C]
const foo: Foo = (a, b, c) => [a, b, c]
const bar1 = foo(1, 2, 3)
//     ^? const bar1: [number, number, number]
const bar2 = foo<number>(1, 2, 3)
//     ^? const bar2: [number, any, any]  - Passing just one explicit generic to `foo` removes type inference

⭐️ Generic 인수를 부분적으로 전달할 때, 나머지는 기본값을 따라갈 뿐입니다. 이것이 부분 타입추론의 문제입니다.

그럼, swr에서 발생한 문제를 짚어보기 위해 swr을 단순화한 버전의 SWRHook을 만들어봅니다.

type MySWRConfig<Data> = { fallbackData?: Data };

type BlockingData<Data = any, Options = MySWRConfig<Data>> = Options extends undefined
  ? false
  : Options extends { fallbackData: Data; }
  ? true
  : false;

type MySWRResponse<Data, Options = MySWRConfig<Data>> = {
  data: BlockingData<Data, Options> extends true ? Data : Data | undefined;
};

interface MyHook {
  <Data>(key: string): MySWRResponse<Data>;
  <Data, Config extends MySWRConfig<Data> | undefined = MySWRConfig<Data>>(key: string, config: Config | undefined): MySWRResponse<Data, Required<Config>>;
}

const mySWR: MyHook = () => {}

mySWR을 통해 확인해봅시다.

type User = {
  name: string;
  age: number
};

const fallbackData: User = { name: 'Jonghwan', age: 17 };

const t0 = mySWR<User>('/');
const t1 = mySWR<User>('/', {});
const t2 = mySWR<User>('/', { fallbackData });

⭐️ Quiz. mySWR로 작성된 t0, t1, t2 중 BlockingData가 아닌 것(data=undefined가 될 수 있는)은 무엇이고 왜 그럴까요?

앞서서 부분 타입추론은 완벽하지 않다고 했습니다. 세 가지 경우, 모두 다 Generic 유형을 완벽히 지정해주지 않았습니다.

그렇다면 부분추론 제한에 의해, Config는 기본값인 MySWRConfig<Data>가 맵핑됩니다.(이때 실제 함수(mySWR)를 호출할 때 전달한 config의 모양이 어떻든 상관없습니다.)

실제 전달된 option의 모양과는 무관하게, Generic 유형은 있다고 판단되니 반환 타입은 Requred<Config>…, 이 타입은 { fallbackData: Data } 유형이 되고.. BlockingData 검사에는 통과하고, Data가 됩니다.

이것이 우리의 custom SWR hook이 NonNullable한 이유였습니다.

TypeScript AST Viewer

그럼, 부분 타입 추론의 문제를 어떻게 해결할 수 있습니까?

부분타입추론을 이해하고, Generic 유형들을 수정해 볼 수 있습니다. 그리고, TypeScript가 온전히 타입추론할 수 있도록 만들어줍니다.

interface MyHook {
  <Data = any>(key: string): MySWRResponse<Data>;
  // 부분타입추론으로 항상 존재하는 기본값에서 undefined가 될 수 있음으로 변경합니다
- <Data = any, Config extends MySWRConfig<Data> | undefined = MySWRConfig<Data>>(key: string, config: Config | undefined): MySWRResponse<Data, Required<Config>>;
+ <Data = any, Config extends MySWRConfig<Data> | undefined = MySWRConfig<Data> | undefined>(key: string, config: Config): MySWRResponse<Data, Config>;
}

Generic 인수를 모두 추론 가능하게 만들어주거나, 모두 명시적으로 지정합니다.

const config = { fallbackData };

const t0 = mySWR<User>('/');
t0.data.name; // data = undefined

const t1 = mySWR<User>('/', {});
t1.data.name; // data = undefined

const t2 = mySWR<User, typeof config>('/', config);
t2.data.name; // data = User

// 조금 더 본래의 swr을 구현했다고 가정, (fetcher를 포함)
const t3 = mySWR('/', () => { return {} as User }, { });
t3.data // data = User | undefined
const t4 = mySWR('/', () => { return {} as User }, { fallbackData });
t4.data // data = User

만족스럽습니다. 이제야 타입 추론이 정상적으로 동작합니다.

하지만, 개발자가 모든 제네릭 유형을 할당해 주는 일은 현실세계에서는 재사용성을 고려한 추상화의 범위에 따라 어렵고 번거로운 일이 될 가능성이 많습니다.

 

더 나은 방법은 없을까요?

부분 추론 타입이 제한되는 상황에서 Generic Function에 대해 정상적인 추론을 위해 몇 가지 방안이 존재합니다.

TypeScript Playground

 

위 내용 중 커링 방식으로 createSWRHook을 만들어 Custom SWR Hook을 만들었고, 정확하게 타입추론이 될 수 있도록 개선해 볼 수 있습니다.

여기까지 부분타입추론 문제에 대해 알아보았고, 나아가 보다 완벽한 타입추론 상태를 만드는 테크닉에 대해 살펴보았습니다.