T
TanStack•3mo ago
adverse-sapphire

Can `useQuery`'s result type figure out that all errors throw?

It's wonderful that the typings can reassure me that if status is neither pending nor error, I can rely on data being defined. It seems that it would be even better if they could somehow realize that because throwOnError is set, one only needs to eliminate pending in order to guarantee this state of affairs. Is this a deliberate design decision, am I doing things wrongly, or is it some sort of inherent TypeScript limitation?
7 Replies
correct-apricot
correct-apricot•3mo ago
By default typescript won't tell you if a function will throw or not If you use something like neverthrow or effect.ts , you can get result types that'll have error types
adverse-sapphire
adverse-sapphireOP•3mo ago
Thank you for your response. At a brief glance, both of those libraries you mentioned seem to be more than I was looking to add to the mix of things. I was hoping that some sort of conditional type magic could discern that if the options given contain a truthy value for throwOnError, the type of useQuery's return could be narrowed to exclude the error states, as it is known that they cannot actually come back to the caller.
solid-orange
solid-orange•3mo ago
The return type of your function is all that is used to determine the type of data. As far as I am aware, if you do not manually provide the generics, the only way that Error is inferred is by the type you define in your throwOnError or retry I believe. For example, your options are
// manually
useQuery<ReturnType, ErrorTypes, Selected, QueryKey>

// Inferred
useQuery({
...,
retry(failureCount, error: ErrorType) {

}
})
// or
useQuery({
...,
throwOnError(error: ErrorType) { }
})
// manually
useQuery<ReturnType, ErrorTypes, Selected, QueryKey>

// Inferred
useQuery({
...,
retry(failureCount, error: ErrorType) {

}
})
// or
useQuery({
...,
throwOnError(error: ErrorType) { }
})
If you use queryOptions to create your queries, you can define the types on that, and then have useQuery infer based on the queryOptions as well.
adverse-sapphire
adverse-sapphireOP•3mo ago
Perhaps some sample code will illustrate my concern better.
const qo = queryOptions({
queryKey: ["demo"] as const,
queryFn: () => Promise.resolve({foo: "hello"}),
throwOnError: true,
});
const qo = queryOptions({
queryKey: ["demo"] as const,
queryFn: () => Promise.resolve({foo: "hello"}),
throwOnError: true,
});
If I explicitly eliminate both the pending and error possibilities, TypeScript is able to narrow the result sufficiently for the following dereference all the way to the inner foo without concern:
function useDemo1() {
const tqrv = useQuery(qo);
if (tqrv.isPending) { return; }
if (tqrv.isError) { return; }
const foo = tqrv.data.foo;
}
function useDemo1() {
const tqrv = useQuery(qo);
if (tqrv.isPending) { return; }
if (tqrv.isError) { return; }
const foo = tqrv.data.foo;
}
However, if I only check for pending, the final line causes complaints that tqrv.data might be undefined:
function useDemo2() {
const tqrv = useQuery(qo);
if (tqrv.isPending) { return; }
const foo = tqrv.data.foo; // unsafe
}
function useDemo2() {
const tqrv = useQuery(qo);
if (tqrv.isPending) { return; }
const foo = tqrv.data.foo; // unsafe
}
Because qo's throwOnError is statically known to be true, I thought there was no possible way for the calling code to ever get an error result, and the above code should work just like the first iteration. The best I have managed to do so far is to wrap useQuery like this:
export type InfallibleUseQueryResult<TData, TError>
= Exclude<UseQueryResult<TData, TError>,
QueryObserverRefetchErrorResult<TData, TError>
| QueryObserverLoadingErrorResult<TData, TError>>;

export function useInfallibleQuery<
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey,
>(inOptions: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>): InfallibleUseQueryResult<TData, TError> {
const tqc = useQueryClient();
const defaultQo = tqc.getQueryDefaults(inOptions.queryKey);
const options = inOptions.throwOnError || defaultQo.throwOnError
? inOptions
: {...inOptions, throwOnError: true};
const rv = useQuery(options);
if (rv.isError) {
throw rv.error;
}
return rv;
}

function useDemo3() {
const tqrv = useInfallibleQuery(qo);
if (tqrv.isPending) {
return;
}
const foo = tqrv.data.foo; // no problem now
}
export type InfallibleUseQueryResult<TData, TError>
= Exclude<UseQueryResult<TData, TError>,
QueryObserverRefetchErrorResult<TData, TError>
| QueryObserverLoadingErrorResult<TData, TError>>;

export function useInfallibleQuery<
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey,
>(inOptions: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>): InfallibleUseQueryResult<TData, TError> {
const tqc = useQueryClient();
const defaultQo = tqc.getQueryDefaults(inOptions.queryKey);
const options = inOptions.throwOnError || defaultQo.throwOnError
? inOptions
: {...inOptions, throwOnError: true};
const rv = useQuery(options);
if (rv.isError) {
throw rv.error;
}
return rv;
}

function useDemo3() {
const tqrv = useInfallibleQuery(qo);
if (tqrv.isPending) {
return;
}
const foo = tqrv.data.foo; // no problem now
}
But even doing it this way, I have to explicitly throw an error that should provably never happen.
solid-orange
solid-orange•3mo ago
Ah I see, sorry for the misunderstanding. My understanding with queries is that you essentially have these return states available to you - DefinedQueryObserverResult<TData, TError> - QueryObserverLoadingErrorResult<TData, TError> - QueryObserverLoadingResult<TData, TError> - QueryObserverPendingResult<TData, TError> - QueryObserverPlaceholderResult<TData, TError>; None of these seem to, at a type level, infer whether or not the query can actually return any error data, so I suspect that currently it's just setup in a way that can infer this information for you in typescript. I'm not a maintainer, so can't comment on if this is planned in the future, but my understanding is that you would either need to add a generic for whether it can throw or not and pass this all the way down; or set TError to some custom value such as a symbol, that they can check against in the types to use a new return type that defines data/loading/pending to be possible states, while removing errors. What you essentially have here is half of a suspenseQuery 😂 Anyway, hopefully a maintainer could comment on whether I am wrong, or if this is something that is desired at all moving forward and if there is a timeline on making that type possible
adverse-sapphire
adverse-sapphireOP•3mo ago
my understanding is that you would either need to add a generic for whether it can throw or not
This doesn't actually work (for one thing, it hits a snag on refresh errors), but it's illustrative of where I was aiming:
type SentientUseQueryResult<OptionsT> =
OptionsT extends UseQueryOptions<unknown, infer TError, infer TData> & {
throwOnError: true
}
? InfallibleUseQueryResult<TData, TError>
: (OptionsT extends UseQueryOptions<unknown, infer TError, infer TData>
? UseQueryResult<TData, TError> : never);

export function useSentientQuery<TQueryFnData, TError, TData, TQueryKey extends QueryKey>(inOptions: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>): SentientUseQueryResult<typeof inOptions> {
return useQuery(inOptions);
}
type SentientUseQueryResult<OptionsT> =
OptionsT extends UseQueryOptions<unknown, infer TError, infer TData> & {
throwOnError: true
}
? InfallibleUseQueryResult<TData, TError>
: (OptionsT extends UseQueryOptions<unknown, infer TError, infer TData>
? UseQueryResult<TData, TError> : never);

export function useSentientQuery<TQueryFnData, TError, TData, TQueryKey extends QueryKey>(inOptions: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>): SentientUseQueryResult<typeof inOptions> {
return useQuery(inOptions);
}
I was hoping it could be done without resorting to template parameters that have to be set manually.
solid-orange
solid-orange•3mo ago
If nothing else, your naming is absolutely hilariously accurate. useInfallibleQuery and useSentientQuery are great names, bravo

Did you find this page helpful?