TanStack Query에 기여하기

Yoomin Kang·2025년 2월 9일
post-thumbnail

이번에 TanStack Query에 Pull Request를 올렸고, Merge되었다. 좋은 경험이었기에 공유해보고자 한다.

https://github.com/TanStack/query/pull/8624


이슈

TanStack Query 레포지토리를 둘러보다 5개월 넘게 해결이 안 되고 있는 이슈를 발견했다.

https://github.com/TanStack/query/issues/7974

다음 코드에서 타입 에러가 발생한다는 제보였다.

const Queries1 = {
  get: () =>
    queryOptions({
      queryKey: key1,
      queryFn: async () => {
        await sleep(10)
        return 1
      },
    }),
}
const Queries2 = {
  get: () =>
    queryOptions({
      queryKey: key2,
      queryFn: async () => {
        await sleep(10)
        return true
      },
    }),
}

const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() }))
const result = useQueries({
  queries: [...queries1List, { ...Queries2.get() }],
})

타입 에러

TanStack Query를 좋아하는 나로서는 기여할 수 있는 절호의 기회라 생각하여 곧바로 VS code를 켰다.

해결방법

문제는 의외로 간단했다. useQueries의 선언은 다음과 같이 이루어진다.

export function useQueries<
  T extends Array<any>,
  TCombinedResult = QueriesResults<T>,
>(
  {
    queries,
    ...options
  }: {
    queries: readonly [...QueriesOptions<T>]
    combine?: (result: QueriesResults<T>) => TCombinedResult
    subscribed?: boolean
  },
  queryClient?: QueryClient,
): TCombinedResult {
	// ...
}

여기서 queries의 readonly [...QueriesOptions<T>][…queries1List, { …Queries2.get() }]와 같은 동적 배열이 포함된 튜플 타입을 처리하지 못해 문제가 발생한 것이다.

이러한 동적 배열이 섞여있는 튜플 타입을 지원하기 위해 queries의 타입에 유니온으로 readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries<T[K]> }]를 추가해주었다.

queries:
  | readonly [...QueriesOptions<T>]
  | readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries<T[K]> }]

이렇게 하면 개별 요소의 타입을 유지할 수 있다.

끝난 줄 알았으나, 사용처에서 타입 추론에 문제가 발생하였다.

const data = result.map((item) => item.data)

에서 data의 타입이 unknown[]으로 추론되는 문제였다. 원래는 (number | boolean | undefined)[]로 추론되어야 정상이다.

이를 해결하기 위해서는 QueriesResults를 수정해야 했다.

원래 QueriesResults는 다음과 같다.

export type QueriesResults<
  T extends Array<any>,
  TResults extends Array<any> = [],
  TDepth extends ReadonlyArray<number> = [],
> = TDepth['length'] extends MAXIMUM_DEPTH
  ? Array<UseQueryResult>
  : T extends []
    ? []
    : T extends [infer Head]
      ? [...TResults, GetUseQueryResult<Head>]
      : T extends [infer Head, ...infer Tails]
        ? QueriesResults<
            [...Tails],
            [...TResults, GetUseQueryResult<Head>],
            [...TDepth, 1]
          >
        : T extends Array<
              UseQueryOptionsForUseQueries<
                infer TQueryFnData,
                infer TError,
                infer TData,
                any
              >
            >
          ? // Dynamic-size (homogenous) UseQueryOptions array: map directly to array of results
            Array<
              UseQueryResult<
                unknown extends TData ? TQueryFnData : TData,
                unknown extends TError ? DefaultError : TError
              >
            >
          : // Fallback
            Array<UseQueryResult>

이번 문제도 간단히 해결되었다. 마지막 Fallback을 Array<GetUseQueryResult<T[number]>>로 수정하였더니 data가 깔끔히 추론되었고, 다음 테스트 코드가 통과했다.

expectTypeOf(result[0]?.data).toEqualTypeOf<
  number | boolean | undefined
>()

그리고, 한 가지 문제를 더 발견했다.

result의 타입이 이상하게 추론되고 있었다.

이상한 result 타입추론

[
  ...Array<UseQueryResult<number, Error>>,
  UseQueryResult<boolean, Error>,
]

로 추론되기를 원하기에, 다음과 같이 테스트 코드를 작성했다.

expectTypeOf(result).toEqualTypeOf<
  [
    ...Array<UseQueryResult<number, Error>>,
    UseQueryResult<boolean, Error>,
  ]
>()

다시 QueriesResults를 수정했더니 result의 타입이 제대로 추론되었고, 테스트 코드가 통과되었다.

export type QueriesResults<
  T extends Array<any>,
  TResults extends Array<any> = [],
  TDepth extends ReadonlyArray<number> = [],
> = TDepth['length'] extends MAXIMUM_DEPTH
  ? Array<UseQueryResult>
  : T extends []
    ? []
    : T extends [infer Head]
      ? [...TResults, GetUseQueryResult<Head>]
      : T extends [infer Head, ...infer Tails]
        ? QueriesResults<
            [...Tails],
            [...TResults, GetUseQueryResult<Head>],
            [...TDepth, 1]
          >
        : {
            [K in keyof T]: T[K] extends UseQueryOptionsForUseQueries<
              infer TQueryFnData,
              infer TError,
              infer TData,
              any
            >
              ? UseQueryResult<
                  unknown extends TData ? TQueryFnData : TData,
                  unknown extends TError ? DefaultError : TError
                >
              : GetUseQueryResult<T[number]>
          }

바뀐 부분을 비교하면 다음과 같다.

원래 코드

T extends Array<
    UseQueryOptionsForUseQueries<
      infer TQueryFnData,
      infer TError,
      infer TData,
      any
    >
  >
? Array<
    UseQueryResult<
      unknown extends TData ? TQueryFnData : TData,
      unknown extends TError ? DefaultError : TError
    >
  >
: Array<GetUseQueryResult<T[number]>>

1차 수정 코드

{
  [K in keyof T]: T[K] extends UseQueryOptionsForUseQueries<
    infer TQueryFnData,
    infer TError,
    infer TData,
    any
  >
    ? UseQueryResult<
        unknown extends TData ? TQueryFnData : TData,
        unknown extends TError ? DefaultError : TError
      >
    : GetUseQueryResult<T[number]>
}

TUseQueryOptionsForUseQueries의 형태를 띄는 서로다른 요소로 이루어진 배열일 경우에

T extends Array<
    UseQueryOptionsForUseQueries<
      infer TQueryFnData,
      infer TError,
      infer TData,
      any
    >
  >

가 false로 추론되기에 배열을 순회하면서 각 요소의 타입을 개별적으로 맵핑해주었고, 덕분에 각 인덱스별로 정확한 타입이 보존 가능해졌다.

이제 문제가 완전히 해결되었…는 줄 알았으나 동일한 방식을 solid-query의 createQueries에도 적용한 결과, 타입 테스트에서 에러가 발생했다.

이를 해결하기 위해 여러가지 시도를 해보았고, 마지막 GetUseQueryResult<T[number]>GetUseQueryResult<T[K]>로 바꿔 문제가 해결되었다. 이제 react-query, vue-query, svelte-query 뿐만 아니라 solid-query에서도 완벽히 작동한다!

그리고, 더 단순화할 수 없을까 생각해본 결과, 최종적으로 다음과 같이 코드가 완성되었다.

export type QueriesResults<
  T extends Array<any>,
  TResults extends Array<any> = [],
  TDepth extends ReadonlyArray<number> = [],
> = TDepth['length'] extends MAXIMUM_DEPTH
  ? Array<UseQueryResult>
  : T extends []
    ? []
    : T extends [infer Head]
      ? [...TResults, GetUseQueryResult<Head>]
      : T extends [infer Head, ...infer Tails]
        ? QueriesResults<
            [...Tails],
            [...TResults, GetUseQueryResult<Head>],
            [...TDepth, 1]
          >
        : { [K in keyof T]: GetUseQueryResult<T[K]> }

다시 바뀐 부분을 비교하면 다음과 같다.

1차 수정 코드

{
  [K in keyof T]: T[K] extends UseQueryOptionsForUseQueries<
    infer TQueryFnData,
    infer TError,
    infer TData,
    any
  >
    ? UseQueryResult<
        unknown extends TData ? TQueryFnData : TData,
        unknown extends TError ? DefaultError : TError
      >
    : GetUseQueryResult<T[number]>
}

2차 수정 코드

{
  [K in keyof T]: T[K] extends UseQueryOptionsForUseQueries<
    infer TQueryFnData,
    infer TError,
    infer TData,
    any
  >
    ? UseQueryResult<
        unknown extends TData ? TQueryFnData : TData,
        unknown extends TError ? DefaultError : TError
      >
    : GetUseQueryResult<T[K]>
}

3차 수정 코드

{ [K in keyof T]: GetUseQueryResult<T[K]> }

굳이 UseQueryOptionsForUseQueries를 extend하는지 비교하지 않고 직접 GetUseQueryResult<T[K]>를 사용하여 많이 간단해졌다.

결론

이번 문제는 useQueries, useSuspenseQueriescreateQueries의 타입 추론이 튜플과 배열을 정확하게 처리하지 못하는 문제에서 시작되었다.

이를 해결하기 위해 다음과 같은 개선을 적용했다.

  1. queries 타입 개선
    • readonly [...QueriesOptions<T>]만으로는 동적 배열이 포함된 튜플을 처리할 수 없었음
    • 이를 해결하기 위해 readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries<T[K]> }]을 추가하여 개별 요소의 타입을 유지할 수 있도록 변경
  2. QueriesResults 개선
    • 기존에는 배열로 변환되어 튜플 타입이 무너지는 문제가 있었음
    • 배열을 순회하면서 개별 요소의 타입을 맵핑하는 방식으로 변경하여 각 인덱스별 타입을 정확하게 보존하도록 수정
  3. 타입 테스트를 작성하고 통과하도록 수정
    • result[...UseQueryResult<number, Error>[], UseQueryResult<boolean, Error>]처럼 개별 요소의 타입을 정확히 반영하도록 변경

이번 개선을 통해 useQueries, useSuspenseQueriescreateQueries는 튜플과 배열을 정확하게 처리할 수 있는 타입 추론을 갖추게 되었으며,
각 요소의 타입이 유지되는 방식으로 변경되어 더 정밀한 타입 안전성을 제공할 수 있게 되었다.

profile
FE Developer @Toss | GSHS 36 | Korea Univ 21

0개의 댓글