diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed2..50d4448f6e 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -93,6 +93,92 @@ export type InferErrorFromTag = : TaggedError : TError +// Throws type for typed errors in function return types +export const throwsSymbol = Symbol('throws') +export type throwsSymbol = typeof throwsSymbol + +/** + * Phantom type to mark throwable errors in function return types. + * Use this to declare what errors a function can throw. + * + * @example + * ```ts + * async function fetchUser(id: string): Promise> { + * const res = await fetch(`/api/users/${id}`) + * if (!res.ok) throw new ApiError(res.status) + * return res.json() + * } + * + * // Error type is automatically inferred as ApiError + * const { error } = useQuery({ + * queryKey: ['user', id], + * queryFn: () => fetchUser(id), + * }) + * ``` + */ +export type Throws = { [throwsSymbol]: TError } + +/** + * Extract the error type from a type that includes Throws + */ +export type ExtractThrows = + IsAny extends true ? never : T extends Throws ? E : never + +/** + * Extract the error type from a function's return type. + * Works with both sync functions returning T and async functions returning Promise + */ +export type ExtractThrowsFromReturnType = + IsAny extends true + ? never + : T extends (...args: Array) => infer R + ? R extends Promise + ? ExtractThrows + : ExtractThrows + : never + +/** + * Infer the error type from a function. + * If the function's return type includes Throws, returns E. + * Otherwise returns the fallback type (defaults to DefaultError). + */ +export type InferErrorFromFn = + ExtractThrowsFromReturnType extends never + ? TFallback + : ExtractThrowsFromReturnType + +/** + * Remove the Throws phantom type from a type. + * If T is `Data & Throws`, returns `Data`. + * If T doesn't include Throws, returns T unchanged. + */ +export type StripThrows = + IsAny extends true ? T : T extends Throws ? Omit : T + +/** + * Check if a type is `any`. + * Uses the trick that `0 extends (1 & T)` is only true when T is `any`. + */ +export type IsAny = 0 extends 1 & T ? true : false + +/** + * Check if a function's return type includes Throws. + * Uses ExtractThrowsFromReturnType and guards against `any`. + */ +export type HasThrows = + IsAny> extends true + ? true + : [ExtractThrowsFromReturnType] extends [never] + ? false + : true + +/** + * Helper type for overloads that should only match when queryFn/mutationFn uses Throws. + * Returns TOptions when Throws is present, never otherwise. + */ +export type ThrowsFnOptions = + HasThrows extends true ? TOptions : never + export type QueryFunction< T = unknown, TQueryKey extends QueryKey = QueryKey, diff --git a/packages/react-query/src/__tests__/mutationOptions.test-d.tsx b/packages/react-query/src/__tests__/mutationOptions.test-d.tsx index 2988426d65..3796cee3e8 100644 --- a/packages/react-query/src/__tests__/mutationOptions.test-d.tsx +++ b/packages/react-query/src/__tests__/mutationOptions.test-d.tsx @@ -4,8 +4,10 @@ import { useIsMutating, useMutation, useMutationState } from '..' import { mutationOptions } from '../mutationOptions' import type { DefaultError, + InferErrorFromFn, MutationFunctionContext, MutationState, + Throws, WithRequired, } from '@tanstack/query-core' import type { UseMutationOptions, UseMutationResult } from '../types' @@ -214,4 +216,68 @@ describe('mutationOptions', () => { }), }) }) + + describe('Throws pattern for typed errors', () => { + // Custom error type + class ApiError extends Error { + constructor( + public code: number, + message: string, + ) { + super(message) + } + } + + // Data and variables types + type User = { id: string; name: string } + type CreateUserInput = { name: string } + + // Function that declares what error it can throw using Throws + const createUser = async ( + _input: CreateUserInput, + ): Promise> => { + throw new ApiError(400, 'Validation failed') + } + + it('should allow explicit error type using generics', () => { + const mutation = useMutation({ + mutationFn: createUser, + }) + + expectTypeOf(mutation.data).toEqualTypeOf() + expectTypeOf(mutation.error).toEqualTypeOf() + }) + + it('should infer error type from Throws return type', () => { + const mutation = useMutation({ + mutationFn: createUser, + }) + + expectTypeOf(mutation.data).toEqualTypeOf() + expectTypeOf(mutation.error).toEqualTypeOf() + }) + + it('should allow using InferErrorFromFn helper to extract error type', () => { + type CreateUserError = InferErrorFromFn + expectTypeOf().toEqualTypeOf() + + const mutation = useMutation< + User, + InferErrorFromFn, + CreateUserInput + >({ + mutationFn: createUser, + }) + + expectTypeOf(mutation.data).toEqualTypeOf() + expectTypeOf(mutation.error).toEqualTypeOf() + }) + + it('should infer DefaultError when function does not use Throws', () => { + const mutateData = async (): Promise => 'data' + + type MutateDataError = InferErrorFromFn + expectTypeOf().toEqualTypeOf() + }) + }) }) diff --git a/packages/react-query/src/__tests__/useQuery.test-d.tsx b/packages/react-query/src/__tests__/useQuery.test-d.tsx index 7e99666beb..1da912533c 100644 --- a/packages/react-query/src/__tests__/useQuery.test-d.tsx +++ b/packages/react-query/src/__tests__/useQuery.test-d.tsx @@ -2,7 +2,13 @@ import { describe, expectTypeOf, it } from 'vitest' import { queryKey } from '@tanstack/query-test-utils' import { useQuery } from '../useQuery' import { queryOptions } from '../queryOptions' -import type { OmitKeyof, QueryFunction, UseQueryOptions } from '..' +import type { + InferErrorFromFn, + OmitKeyof, + QueryFunction, + Throws, + UseQueryOptions, +} from '..' describe('useQuery', () => { const key = queryKey() @@ -102,10 +108,11 @@ describe('useQuery', () => { }) // it should handle query-functions that return Promise - useQuery({ + const anyQuery = useQuery({ queryKey: key, queryFn: () => fetch('return Promise').then((resp) => resp.json()), }) + expectTypeOf(anyQuery.error).toEqualTypeOf() // handles wrapped queries with custom fetcher passed as inline queryFn const useWrappedQuery = < @@ -338,4 +345,99 @@ describe('useQuery', () => { }) }) }) + + describe('Throws pattern for typed errors', () => { + // Custom error type + class ApiError extends Error { + constructor( + public code: number, + message: string, + ) { + super(message) + } + } + + // Data type + type User = { id: string; name: string } + + // Function that declares what error it can throw using Throws + const fetchUser = async (_id: string): Promise> => { + throw new ApiError(404, 'User not found') + } + + it('should allow explicit error type using generics', () => { + // Option 1: Explicit generics - most straightforward way to type errors + const { data, error } = useQuery({ + queryKey: ['user', '1'], + queryFn: () => fetchUser('1'), + }) + + expectTypeOf(data).toEqualTypeOf() + expectTypeOf(error).toEqualTypeOf() + }) + + it('should infer error type from Throws return type', () => { + const { data, error } = useQuery({ + queryKey: ['user', 'auto'], + queryFn: () => fetchUser('auto'), + }) + + expectTypeOf(data).toEqualTypeOf() + expectTypeOf(error).toEqualTypeOf() + }) + + it('should allow using InferErrorFromFn helper to extract error type', () => { + // Option 2: Use InferErrorFromFn to extract the error type from the function + type FetchUserError = InferErrorFromFn + expectTypeOf().toEqualTypeOf() + + const { data, error } = useQuery< + User, + InferErrorFromFn + >({ + queryKey: ['user', '2'], + queryFn: () => fetchUser('2'), + }) + + expectTypeOf(data).toEqualTypeOf() + expectTypeOf(error).toEqualTypeOf() + }) + + it('should infer DefaultError when function does not use Throws', () => { + // Function without Throws annotation + const fetchData = async (): Promise => 'data' + + type FetchDataError = InferErrorFromFn + expectTypeOf().toEqualTypeOf() + }) + + it('should work with union error types', () => { + class NetworkError extends Error { + type = 'network' as const + } + class ValidationError extends Error { + type = 'validation' as const + } + + type Data = { value: number } + + const fetchWithMultipleErrors = async (): Promise< + Data & Throws + > => { + throw new NetworkError('Network failed') + } + + type InferredError = InferErrorFromFn + expectTypeOf().toEqualTypeOf< + NetworkError | ValidationError + >() + + const { error } = useQuery({ + queryKey: ['data'], + queryFn: fetchWithMultipleErrors, + }) + + expectTypeOf(error).toEqualTypeOf() + }) + }) }) diff --git a/packages/react-query/src/useMutation.ts b/packages/react-query/src/useMutation.ts index 2c66eb8ba8..71b77ed085 100644 --- a/packages/react-query/src/useMutation.ts +++ b/packages/react-query/src/useMutation.ts @@ -12,10 +12,57 @@ import type { UseMutationOptions, UseMutationResult, } from './types' -import type { DefaultError, QueryClient } from '@tanstack/query-core' +import type { + DefaultError, + InferErrorFromFn, + MutationFunction, + QueryClient, + StripThrows, + ThrowsFnOptions, +} from '@tanstack/query-core' + +type MutationFnData) => any> = + StripThrows>> + +type MutationFnVariables) => any> = + Parameters extends [] ? void : Parameters[0] // HOOK +export function useMutation< + TMutationFn extends MutationFunction, + TOnMutateResult = unknown, +>( + options: ThrowsFnOptions< + TMutationFn, + Omit< + UseMutationOptions< + MutationFnData, + InferErrorFromFn, + MutationFnVariables, + TOnMutateResult + >, + 'mutationFn' + > & { mutationFn: TMutationFn } + >, + queryClient?: QueryClient, +): UseMutationResult< + MutationFnData, + InferErrorFromFn, + MutationFnVariables, + TOnMutateResult +> + +export function useMutation< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: UseMutationOptions, + queryClient?: QueryClient, +): UseMutationResult + export function useMutation< TData = unknown, TError = DefaultError, diff --git a/packages/react-query/src/useQuery.ts b/packages/react-query/src/useQuery.ts index 52d479551d..070bb85df9 100644 --- a/packages/react-query/src/useQuery.ts +++ b/packages/react-query/src/useQuery.ts @@ -3,9 +3,13 @@ import { QueryObserver } from '@tanstack/query-core' import { useBaseQuery } from './useBaseQuery' import type { DefaultError, + InferErrorFromFn, NoInfer, QueryClient, + QueryFunction, QueryKey, + StripThrows, + ThrowsFnOptions, } from '@tanstack/query-core' import type { DefinedUseQueryResult, @@ -17,6 +21,54 @@ import type { UndefinedInitialDataOptions, } from './queryOptions' +export function useQuery< + TQueryKey extends QueryKey = QueryKey, + TQueryFn extends QueryFunction = QueryFunction< + any, + TQueryKey + >, + TQueryFnData = StripThrows>>, + TData = TQueryFnData, +>( + options: ThrowsFnOptions< + TQueryFn, + Omit< + DefinedInitialDataOptions< + TQueryFnData, + InferErrorFromFn, + TData, + TQueryKey + >, + 'queryFn' + > & { queryFn: TQueryFn } + >, + queryClient?: QueryClient, +): DefinedUseQueryResult, InferErrorFromFn> + +export function useQuery< + TQueryKey extends QueryKey = QueryKey, + TQueryFn extends QueryFunction = QueryFunction< + any, + TQueryKey + >, + TQueryFnData = StripThrows>>, + TData = TQueryFnData, +>( + options: ThrowsFnOptions< + TQueryFn, + Omit< + UndefinedInitialDataOptions< + TQueryFnData, + InferErrorFromFn, + TData, + TQueryKey + >, + 'queryFn' + > & { queryFn: TQueryFn } + >, + queryClient?: QueryClient, +): UseQueryResult, InferErrorFromFn> + export function useQuery< TQueryFnData = unknown, TError = DefaultError,