TanStack / query

🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.
https://tanstack.com/query
MIT License
40.3k stars 2.7k forks source link

feat(types): useQueries to flow through types #1527

Closed johnnyreilly closed 3 years ago

johnnyreilly commented 3 years ago

Hello @tannerlinsley!

Merry Christmas and thank you for react-query; it's awesome! 🎄

I've just been trying out useQueries in react-query 3 and it's really cool! However one of the things I bumped on was the return type reliably being QueryObserverResult<unknown, unknown>[]. This means I have to assert the type before I can use it.

This can be improved by supporting generics for useQueries just as useQuery does. This PR adds that support; very much modelled on the code of useQuery. It also adds a test which asserts that types flow through in the way we'd hope for.

This should be a backwards compatible change; all existing users will be experiencing QueryObserverResult<unknown, unknown>[] and then having to subsequently assert types. That should all still work, but if they would like, they'll no longer need that code.

What do you think?

vercel[bot] commented 3 years ago

This pull request is being automatically deployed with Vercel (learn more).
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: https://vercel.com/tannerlinsley/react-query/m1scq9ht1
✅ Preview: https://react-query-git-fork-johnnyreilly-master.tannerlinsley.vercel.app

johnnyreilly commented 3 years ago

Ah - I can see I've upset the rules of hooks in the test. I'll fix that but have to break off for now.

johnnyreilly commented 3 years ago

Fixed.

boschni commented 3 years ago

Hi @johnnyreilly, thanks for the PR! It seems like all queries need to return the same data? Think most of the time each query would require a different type. And is it possible to infer the return types from the given options?

johnnyreilly commented 3 years ago

It seems like all queries need to return the same data? Think most of the time each query would require a different type

The examples of useQueries suggest usage with .map operations in which case the type would be the same each time. However as we'll see - that's by the by!

And is it possible to infer the return types from the given options?

The way it works is through type inference, and so yes, if there are various types returned, that should flow through too!

boschni commented 3 years ago

Great, could you add some tests for those cases?

Same type:

const result = useQueries([
  { queryKey: key1, queryFn: () => 1 },
  { queryKey: key2, queryFn: () => 2 },
])

expectType<number | undefined>(result[0].data)
expectType<number | undefined>(result[1].data)

Different type:

const result = useQueries([
  { queryKey: key1, queryFn: () => 1 },
  { queryKey: key2, queryFn: () => 'data' },
])

expectType<number | undefined>(result[0].data)
expectType<string | undefined>(result[1].data)
johnnyreilly commented 3 years ago

Yup, the same type case is covered by the test I added. It's a good shout to cover the different type case as well. I'll try to add when I'm next near a keyboard

johnnyreilly commented 3 years ago

I've added a test that covers the different type use case as well. It's actually subtly different than I anticipated, where there is a variety of return types you need to specify the variety of types explicitly (or do useQueries<unknown> if you want the existing behaviour. So the typed API does slightly change here (although the executed JavaScript remains identical).

It's arguable that this would make it a breaking change from a type perspective (though for the atypical use case).

boschni commented 3 years ago

Ideally users should not have to specify generics explicitly and it would be great if the types could be inferred correctly for each query instead of creating a union. Would you be able to get that working?

johnnyreilly commented 3 years ago

I agree that would be ideal - I've had a go but I've been unsuccessful. It may not be possible with the TypeScript type system as is - or beyond my abilities.

The plus with this approach is that specifying the generics is only necessary for the edge case. Not perfect but an improvement on the current casting always scenario

boschni commented 3 years ago

No problem! Although not perfect, it does add typing for some use cases. Does it default to unknown when not specifying the generic and using queries with different types?

const result = useQueries([
  { queryKey: key1, queryFn: () => 1 },
  { queryKey: key2, queryFn: () => 'two' },
])
johnnyreilly commented 3 years ago

To take your specific case:

      const resultWithoutUsingMap = useQueries([
        { queryKey: key1, queryFn: () => 1 },
        { queryKey: key2, queryFn: () => 'two' },
      ])

The compiler will complain that the second array element is not a match for the first; so no defaulting to unknown:

Type '() => string' is not assignable to type 'QueryFunction<number>'.
  Type 'string' is not assignable to type 'number | Promise<number>'.

This is remedied by supplying the types like so:

      const resultWithoutUsingMap = useQueries<number | string>([
        { queryKey: key1, queryFn: () => 1 },
        { queryKey: key2, queryFn: () => 'two' },
      ])

However, there's a better choice; using .map. It works out like this:

      const resultWithAllTheSameTypes = useQueries(
        [1, 2].map(x => ({ queryKey: `${x}`, queryFn: () => x }))
      )
      // resultWithAllTheSameTypes: QueryObserverResult<number, unknown>[]

      const resultWithDifferentTypes = useQueries(
        [1, 'two', new Date()].map(x => ({ queryKey: `${x}`, queryFn: () => x }))
      )
      // resultWithDifferentTypes: QueryObserverResult<string | number | Date, unknown>[]

Type inference in all cases that use map which is really lovely; the TypeScript compiler very much working in our favour here.

TkDodo commented 3 years ago

Type inference in all cases that use map which is really nice.

Hmm, I guess we will have to supply the types with generics then a lot, because I don't think that e.g.

QueryObserverResult<number | string, unknown>[]

will be helpful a lot. The first element in the Array will be number | string' , as will the second one. If I want to pass the first result to a component that accepts anumberand the second one to a component that accepts astring, I still can't do that. Unions likestring | number` are very hard to work with without further type narrowing.

What's also sub-optimal about the .map approach is that with TS 4.1 and --noUncheckedIndexedAccess on, I will also get a potentially undefined value when accessing something from the Array. If the length of the array we pass in is known / static / a tuple, it would be awesome if we could get a tuple back.

johnnyreilly commented 3 years ago

Unions like string | number are very hard to work with without further type narrowing.

True - but this is already the case with useQueries current signature of QueryObserverResult<unknown, unknown>[]. Manual narrowing or casting is required to get to a place where you can perform an operation. That's what this PR ideally hopes to reduce; you should need to do that less as a consequence. Only in certain use cases as opposed to all use cases.

it would be awesome if we could get a tuple back.

Agree it would be, it doesn't look like the compiler supports this use case as yet. Certainly as far as I'm aware.

What's also sub-optimal about the .map approach is that with TS 4.1 and --noUncheckedIndexedAccess on, I will also get a potentially undefined value when accessing something from the Array.

It probably depends how you're using it. I've a project that heavily uses useQueries. In my own case I've always been mapping over the results of useQueries; I've not had the need to index into the results.

Small side note: it was that project that lead me to contributing this PR. My motivation was disatisfaction with the current choice between lack of type safety with recasting after each useQueries call and reperforming type narrowing to satisfy the compiler that a type was still a given type even though that hasn't changed.

TkDodo commented 3 years ago

@johnnyreilly I agree that this PR makes the current situation a lot better, because queries are now typable at all as compared to not-at-all before, so thanks for your contribution 👍 . I just wanted to see if we can take it any further, but it doesn't seem easily doable. The .map use-case with the same return type will probably be what useQueries is used most, because you can create a dynamic amount of queries (likely for the same endpoint) with it. For a static amount, separate useQuery calls are also possible.

boschni commented 3 years ago

I do like the additional typing, but I do not like that it will error in a valid use case:

const result = useQueries([
  { queryKey: key1, queryFn: () => 1 },
  { queryKey: key2, queryFn: () => 'two' },
])
// compiler error

This will probably surprise users and they might not know how to fix it...

johnnyreilly commented 3 years ago

This will probably surprise users and they might not know how to fix it...

That's a reasonable concern. I'd be happy to add documentation which covers this.

johnnyreilly commented 3 years ago

Proposed docs to add to the useQueries page:

TypeScript users

If you're using react-query with TypeScript then generally you'll be able to benefit from type inference:

      const resultWithAllTheSameTypes = useQueries(
        [1, 2].map(x => ({ queryKey: `${x}`, queryFn: () => x }))
      )
      // resultWithAllTheSameTypes: QueryObserverResult<number, unknown>[]

      const resultWithDifferentTypes = useQueries(
        [1, 'two', new Date()].map(x => ({ queryKey: `${x}`, queryFn: () => x }))
      )
      // resultWithDifferentTypes: QueryObserverResult<string | number | Date, unknown>[]

In both the examples above, no types were specified and the compiler correctly inferred the types from the array passed to useQueries.

However, if you pass an array literal where different elements have a queryFn with differing return types then you may encounter a compiler error. Consider:

      const resultWithoutUsingMap = useQueries([
              { queryKey: key1, queryFn: () => 1 },
              { queryKey: key2, queryFn: () => 'two' },
      ])

This will result in an error along the lines of Type '() => string' is not assignable to type 'QueryFunction<number>'.

This can be remedied by supplying the union of possible data types as a type parameter; like so:

      const resultWithoutUsingMap = useQueries<number | string>([
              { queryKey: key1, queryFn: () => 1 },
              { queryKey: key2, queryFn: () => 'two' },
      ])
johnnyreilly commented 3 years ago

I've added the docs - happy to tweak.

boschni commented 3 years ago

I don't think the .map example is very useful in practice because it is basically defining one query function with all the different types in the array as return value. It does not happen often that the result is the same as the input.

I'm not entirely sure how to proceed here. The PR adds typing for single query functions, but at the same time it introduces a compiler error when using multiple query functions. And even when users fix the compiler error by explicitly specifying the return type union of all query functions, it wouldn't be that useful because users would still need to narrow down the results.

Personally I would prefer an implementation which covers all cases, or if not possible, one which degrades gracefully. This is a matter of opinion though, maybe others think differently. Would just adding the ability to specify a return type without type inference be an option? That would at least be a bit more robust.

tannerlinsley commented 3 years ago

I too would prefer a more complete type.

johnnyreilly commented 3 years ago

I too would prefer a more complete type.

I would as well - I don't think the compiler supports that I'm afraid.

I don't think the .map example is very useful in practice because it is basically defining one query function with all the different types in the array as return value. It does not happen often that the result is the same as the input.

I'd actually challenge this. I've a project that uses useQueries heavily and entirely using the map approach. I appreciate different people may have different use cases. It seems likely that using .map is likely to be a common, if not the majority, use case. In my own case, I always use different useQuery and useQueries for different data types

Would just adding the ability to specify a return type without type inference be an option?

This is possible yes. In my opinion this isn't that useful as it introduces the possibility for user error; it becomes a user specifying the assertion up front and that assertion may be incorrect. Essentially it doesn't really offer more than already is in place.

johnnyreilly commented 3 years ago

As far as I know, the type is as good as it can get. I'm happy to be corrected on that - and if anyone has any suggestions on how to improve what I've implemented then please do make suggestions. A little context - here's a Stack Overflow question I asked which lead me to thinking it couldn't be further improved at present: https://stackoverflow.com/questions/65468485/function-returning-a-variable-length-generic-array-tuple

As the SO answer says, it's possible to narrow the type from the caller using TypeScript 4.0 variadic tuple types, but that would require a runtime (as opposed to type level) API change.

I'm happy to close this PR if it's not desired. Thanks anyway for consideration ❤️🎄

johnnyreilly commented 3 years ago

I've explored the variadic tuple type approach and created a wrapper for useQueries that is strongly typed. This supports the use case for multiple query functions:

import { useQueries, UseQueryOptions, UseQueryResult } from 'react-query';

type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

export function useQueriesTyped<T extends readonly UseQueryOptions[]>(
    ...queries: [...T]
): { [K in keyof T]: UseQueryResult<Awaited<ReturnType<NonNullable<Extract<T[K], UseQueryOptions>['queryFn']>>>> } {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return useQueries(queries as UseQueryOptions<unknown, unknown, unknown>[]) as any;
}

const result = useQueriesTyped({ queryKey: 'key1', queryFn: () => 1 }, { queryKey: 'key2', queryFn: () => 'two' });
// const result: [QueryObserverResult<number, unknown>, QueryObserverResult<string, unknown>]

if (result[0].data) {
    // number
}
if (result[1].data) {
    // string
}

const resultWithAllTheSameTypes = useQueriesTyped(...[1, 2].map((x) => ({ queryKey: `${x}`, queryFn: () => x })));
// const resultWithAllTheSameTypes: QueryObserverResult<number, unknown>[]

if (resultWithAllTheSameTypes[0].data) {
    // number
}

const resultWithDifferentTypes = useQueriesTyped(
    ...[1, 'two', new Date()].map((x) => ({ queryKey: `${x}`, queryFn: () => x }))
);
//const resultWithDifferentTypes: QueryObserverResult<string | number | Date, unknown>[]

if (resultWithDifferentTypes[0].data) {
    // string | number | Date
}

if (resultWithDifferentTypes[1].data) {
    // string | number | Date
}

if (resultWithDifferentTypes[2].data) {
    // string | number | Date
}

This uses type inference which is tremendous, satisfies (I think) all the use cases desired. However, it's a different API; useQueries moves from taking a single parameter which is an array of UseQueryOptions, to taking rest parameters which are an array of UseQueryOptions.

What do you think about potentially using this approach? If it was applied directly to the useQueries API it would be a breaking change; but it is a way to have a strongly typed API. For now I'm using the wrapper in my own projects and I'm very happy with it, it would be great for everyone to get the benefit without wrapping.

I've written this up here: https://blog.johnnyreilly.com/2021/01/strongly-typing-react-query-s-usequeries.html

coffenbacher commented 3 years ago

Thank you @johnnyreilly, this was by far the roughest edge I've found so far in react-query and your useQueriesTyped solution is super slick. FWIW, I also exclusively use useQueries for multiple queries of the same type, so the current PR is a good solution for my use-case as well.

johnnyreilly commented 3 years ago

Thanks @coffenbacher! I'm curious if there's a desire to move the strongly typed version of useQueries with a different signature into react-query. I'd be happy to adjust my PR in that direction to that if there was a chance it might land.

boschni commented 3 years ago

Good step into the right direction! Think you can remove the spread operator from the queries argument to keep the interface backwards compatible. There are still some open ends though:

Think you can get those working?

johnnyreilly commented 3 years ago

Think you can remove the spread operator from the queries argument to keep the interface backwards compatible

No - it's variadic tuple types that allows the strong types to flow through in the positional way desired (see discussion above). So it would be a breaking change if taken.

johnnyreilly commented 3 years ago

On the other points:

It is not possible to define the error types.

I think this tweak resolves that:

import { useQueries, UseQueryOptions, UseQueryResult } from 'react-query';

type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

export function useQueriesTyped<
    TQueries extends readonly UseQueryOptions<TData, TError>[],
    TQueryFnData = unknown,
    TError = unknown,
    TData = TQueryFnData
>(
    ...queries: [...TQueries]
): {
    [ArrayElement in keyof TQueries]: UseQueryResult<
        Awaited<ReturnType<NonNullable<Extract<TQueries[ArrayElement], UseQueryOptions>['queryFn']>>>,
        TError
    >;
} {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return useQueries(queries as UseQueryOptions<unknown, unknown, unknown>[]) as any;
}

The callbacks like onSuccess are untyped.

I don't think this is possible - I have experimented without success.

The select type transformation is not supported.

Not sure what this refers to.

boschni commented 3 years ago

No - it's variadic tuple types that allows the strong types to flow through in the positional way desired (see discussion above). So it would be a breaking change if taken.

If I strip the spread operator from ...queries then it does seem to work here.

I think this tweak resolves that

Can you give an example on how the usage would look like?

I don't think this is possible - I have experimented without success.

This also applies to other properties like initialData, they would be untyped which means you can set anything. Might not be a deal breaker but would be great to get it working properly.

Not sure what this refers to.

It refers to the select functionality. It's currently not possible to select data like: select: data => data.someProp.

johnnyreilly commented 3 years ago

If I strip the spread operator from ...queries then it does seem to work here.

Yeah - you're right; I've applied the change to the PR. My proposed solution around errors didn't work out so I've undone that.

With regards the select / initialData / onSuccess etc it's all in the same bracket; I don't think we can flow the types through there; which is a shame but not the end of the world.

So the PR is now non-breaking changes; just a feature.

boschni commented 3 years ago

With regards the select / initialData / onSuccess etc it's all in the same bracket; I don't think we can flow the types through there; which is a shame but not the end of the world.

The one I am most concerned about is the select property because the data type would be incorrect:

const results = useQueries([{ queryKey: 'key', queryFn: () => ({ prop: 'value' }), select: x => x.prop }])
results[0].data.prop // <-- TS thinks this is fine while data is actually a string

While I would love to see additional type inference for useQueries, I personally don't think it should come at the expense of compiler errors or incorrect types (excluding unknown) in certain use cases...

johnnyreilly commented 3 years ago

While I would love to see additional type inference for useQueries, I personally don't think it should come at the expense of compiler errors or incorrect types (excluding unknown) in certain use cases...

As I understand it there's no change in behaviour here. This PR doesn't solve the problem of type inference for select etc but it provides type inference for the general case.

I've tested with the present useQueries and the amended one with this PR and I can't replicate your experience.

Both versions error out with this:

Object is of type 'unknown'.ts(2571)

on

select: x => x.prop

Where we try to x.prop - that's where the error is thrown, as x: unknown.

So this PR doesn't change the experience of using select with useQueries; it remains identical. This PR does provide type safety generally. You will still need to use type assertions / type narrowing with select as you do presently.

johnnyreilly commented 3 years ago

Oh wait - I see what select does. It's (unsurprisingly) a selector on the data. Hmmm... Not sure what to suggest here.

results[0].data.prop // <-- TS thinks this is fine while data is actually a string

You're right - this is a problem

johnnyreilly commented 3 years ago

Okay I've been digging away on this and it looks like flowing through the select type isn't possible because TypeScript doesn't support existential generic types. See detail here and here.

So I've taken a different tack. If a select is provided in one of the queries, then the return type of that query falls back to unknown. This is the same behaviour as at present.

But if select is not provided then you get type safety. How does this sound? I've added a test for this new behaviour.

tannerlinsley commented 3 years ago

Seems like you would be able to use the return type of the select function conditionally, but I'm not sure... I need to learn more TS 😂

johnnyreilly commented 3 years ago

Let me experiment....

johnnyreilly commented 3 years ago

It works! There's one caveat but it's minor:

Okay I've been digging away on this and it looks like flowing through the select type isn't possible because TypeScript doesn't support existential generic types. See detail here and here.

The thing that isn't possible, is flowing through the data type to the input of the select. This is the same as the current API of useQueries; the input is unknown and requires type narrowing or type assertions.

However, as suggested by @tannerlinsley, we can get the return type of select and use it as the return type where it is present. Where select isn't supplied then the return type of the query will be inferred instead.

The PR has been updated to use this combined approach (and test has been added to cover it).

So this PR now represents the addition of strong typing to useQueries in a backwards compatible fashion.

boschni commented 3 years ago

Couple of thoughts:

Current implementation

  1. Simple and predictable.
  2. No type inference (adding support for generics like useQueries<string, Error> would be a good addition though).

This implementation

  1. Type inference for the query result! 👍
  2. It might confuse people as some things are inferred and some are not.
  3. It's a bit hacky, more workarounds might be needed if other options are added.
  4. If it does not work correctly for someone, it's not easy to fix it manually by specifying generics like useQueries<string, Error>.

This is probably as far as it can get without existential generic types. Both implementations have pros and cons, so I guess it comes down to what "we" think is more important.

johnnyreilly commented 3 years ago

Thanks for commenting @boschni. I agree the current implementation isn't perfect with regards to having to supply type assertions with select. However, as you say, that's a limitation of the language.

For my money the advantage of taking this is reduced bugs. Using the current implementation it's unfortunately easy to inadvertently create a bug by using type assertions incorrectly. I know this as I did. 😄 Others are bumping on this too - from the sounds of it @coffenbacher is in that category - as is @matthewdavidrodgers in #1675.

My view is that it would be a good idea to merge this as is. The typed approach has been working well for my own use cases and I'm not aware of any problematic use cases. But I appreciate I'm biased 😉

I wanted to suggest another approach that we could take. Rather than having this become the canonical useQueries, the existing one could stay as is and this updated one could land alongside it as useQueriesTyped. Alternatively, this could become the new useQueries and the existing one could continue existing but be renamed useQueriesRaw or similar.

The advantage of this approach is that it's "in-the-box" but opt-in or opt-outable (depending upon whether it's the non default or default implementation respectively).

Essentially it allows people to opt in and out as they so choose.

As I say, my view is that this is good to land as is. But pivoting to the dual approach would still be a good outcome.

tannerlinsley commented 3 years ago

Marking as stale and closing for now, but would like to see some more movement here in the future. Comment or reopen if/when more information comes.

johnnyreilly commented 3 years ago

If there's something I can do to advance this I'd love to. I've suggested a few proposed ways forward here: https://github.com/tannerlinsley/react-query/pull/1527#issuecomment-761564193

If one of these works, or there's an alternative you have in mind then please do suggest it.