TypeScript의 꽃🌷이라 할 수 있는 타입추론(Type Inference)과 Generic Type의 부분 타입추론 문제에 대해 알아봅니다.
이 주제에 대해 관심이 생긴다면 꼭 끝까지 파헤쳐 개념을 이해하시길 바랍니다.
이 글은 타입스크립트를 활용해 보았으며, Generic Type에 대한 기본적인 이해가 있음을 바탕으로 합니다.
TL;DR
TypeScript Generic은 완벽하지 않습니다. 조금 더 자세히 말해보자면 Generic의 부분적인 추론은 완벽하지 않습니다.
TypeScript는 모든 Generic에 대해서 추론해 낼 수 있지만, 부분적인 추론에 대해서는 지원하지 않습니다. (적어도 현재까지는)
부분 추론 타입이 제한되는 상황에서 Generic Function에 대해 정상적인 추론을 위해 몇 가지 방안이 존재합니다.
예시로 등장하는 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한 이유였습니다.
그럼, 부분 타입 추론의 문제를 어떻게 해결할 수 있습니까?
부분타입추론을 이해하고, 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에 대해 정상적인 추론을 위해 몇 가지 방안이 존재합니다.
위 내용 중 커링 방식으로 createSWRHook을 만들어 Custom SWR Hook을 만들었고, 정확하게 타입추론이 될 수 있도록 개선해 볼 수 있습니다.
여기까지 부분타입추론 문제에 대해 알아보았고, 나아가 보다 완벽한 타입추론 상태를 만드는 테크닉에 대해 살펴보았습니다.
'개발 > 기타' 카테고리의 다른 글
useRef는 언제 사용하나요? (2) | 2024.09.28 |
---|---|
설계자와 이해당사자: 공감과 소통의 중요성 (1) | 2024.09.28 |
논리적 가능성과 현실적 가능성 사이에서 협력하기 (0) | 2023.09.06 |
Avoid Hasty Abstractions(AHA) (0) | 2023.08.28 |
shadcn/ui (0) | 2023.08.24 |