Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/query-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,92 @@ export type InferErrorFromTag<TError, TTaggedQueryKey extends QueryKey> =
: 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<User & Throws<ApiError>> {
* 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<TError> = { [throwsSymbol]: TError }

/**
* Extract the error type from a type that includes Throws<E>
*/
export type ExtractThrows<T> =
IsAny<T> extends true ? never : T extends Throws<infer E> ? E : never

/**
* Extract the error type from a function's return type.
* Works with both sync functions returning T and async functions returning Promise<T>
*/
export type ExtractThrowsFromReturnType<T> =
IsAny<T> extends true
? never
: T extends (...args: Array<any>) => infer R
? R extends Promise<infer U>
? ExtractThrows<U>
: ExtractThrows<R>
: never

/**
* Infer the error type from a function.
* If the function's return type includes Throws<E>, returns E.
* Otherwise returns the fallback type (defaults to DefaultError).
*/
export type InferErrorFromFn<TFn, TFallback = DefaultError> =
ExtractThrowsFromReturnType<TFn> extends never
? TFallback
: ExtractThrowsFromReturnType<TFn>

/**
* Remove the Throws<E> phantom type from a type.
* If T is `Data & Throws<E>`, returns `Data`.
* If T doesn't include Throws, returns T unchanged.
*/
export type StripThrows<T> =
IsAny<T> extends true ? T : T extends Throws<any> ? Omit<T, throwsSymbol> : 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<T> = 0 extends 1 & T ? true : false

/**
* Check if a function's return type includes Throws<E>.
* Uses ExtractThrowsFromReturnType and guards against `any`.
*/
export type HasThrows<TFn> =
IsAny<ExtractThrowsFromReturnType<TFn>> extends true
? true
: [ExtractThrowsFromReturnType<TFn>] extends [never]
? false
: true

/**
* Helper type for overloads that should only match when queryFn/mutationFn uses Throws<E>.
* Returns TOptions when Throws is present, never otherwise.
*/
export type ThrowsFnOptions<TFn, TOptions> =
HasThrows<TFn> extends true ? TOptions : never

export type QueryFunction<
T = unknown,
TQueryKey extends QueryKey = QueryKey,
Expand Down
66 changes: 66 additions & 0 deletions packages/react-query/src/__tests__/mutationOptions.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<E>
const createUser = async (
_input: CreateUserInput,
): Promise<User & Throws<ApiError>> => {
throw new ApiError(400, 'Validation failed')
}

it('should allow explicit error type using generics', () => {
const mutation = useMutation<User, ApiError, CreateUserInput>({
mutationFn: createUser,
})

expectTypeOf(mutation.data).toEqualTypeOf<User | undefined>()
expectTypeOf(mutation.error).toEqualTypeOf<ApiError | null>()
})

it('should infer error type from Throws return type', () => {
const mutation = useMutation({
mutationFn: createUser,
})

expectTypeOf(mutation.data).toEqualTypeOf<User | undefined>()
expectTypeOf(mutation.error).toEqualTypeOf<ApiError | null>()
})

it('should allow using InferErrorFromFn helper to extract error type', () => {
type CreateUserError = InferErrorFromFn<typeof createUser>
expectTypeOf<CreateUserError>().toEqualTypeOf<ApiError>()

const mutation = useMutation<
User,
InferErrorFromFn<typeof createUser>,
CreateUserInput
>({
mutationFn: createUser,
})

expectTypeOf(mutation.data).toEqualTypeOf<User | undefined>()
expectTypeOf(mutation.error).toEqualTypeOf<ApiError | null>()
})

it('should infer DefaultError when function does not use Throws', () => {
const mutateData = async (): Promise<string> => 'data'

type MutateDataError = InferErrorFromFn<typeof mutateData>
expectTypeOf<MutateDataError>().toEqualTypeOf<Error>()
})
})
})
106 changes: 104 additions & 2 deletions packages/react-query/src/__tests__/useQuery.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -102,10 +108,11 @@ describe('useQuery', () => {
})

// it should handle query-functions that return Promise<any>
useQuery({
const anyQuery = useQuery({
queryKey: key,
queryFn: () => fetch('return Promise<any>').then((resp) => resp.json()),
})
expectTypeOf(anyQuery.error).toEqualTypeOf<Error | null>()

// handles wrapped queries with custom fetcher passed as inline queryFn
const useWrappedQuery = <
Expand Down Expand Up @@ -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<E>
const fetchUser = async (_id: string): Promise<User & Throws<ApiError>> => {
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<User, ApiError>({
queryKey: ['user', '1'],
queryFn: () => fetchUser('1'),
})

expectTypeOf(data).toEqualTypeOf<User | undefined>()
expectTypeOf(error).toEqualTypeOf<ApiError | null>()
})

it('should infer error type from Throws return type', () => {
const { data, error } = useQuery({
queryKey: ['user', 'auto'],
queryFn: () => fetchUser('auto'),
})

expectTypeOf(data).toEqualTypeOf<User | undefined>()
expectTypeOf(error).toEqualTypeOf<ApiError | null>()
})

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<typeof fetchUser>
expectTypeOf<FetchUserError>().toEqualTypeOf<ApiError>()

const { data, error } = useQuery<
User,
InferErrorFromFn<typeof fetchUser>
>({
queryKey: ['user', '2'],
queryFn: () => fetchUser('2'),
})

expectTypeOf(data).toEqualTypeOf<User | undefined>()
expectTypeOf(error).toEqualTypeOf<ApiError | null>()
})

it('should infer DefaultError when function does not use Throws', () => {
// Function without Throws annotation
const fetchData = async (): Promise<string> => 'data'

type FetchDataError = InferErrorFromFn<typeof fetchData>
expectTypeOf<FetchDataError>().toEqualTypeOf<Error>()
})

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<NetworkError | ValidationError>
> => {
throw new NetworkError('Network failed')
}

type InferredError = InferErrorFromFn<typeof fetchWithMultipleErrors>
expectTypeOf<InferredError>().toEqualTypeOf<
NetworkError | ValidationError
>()

const { error } = useQuery<Data, InferredError>({
queryKey: ['data'],
queryFn: fetchWithMultipleErrors,
})

expectTypeOf(error).toEqualTypeOf<NetworkError | ValidationError | null>()
})
})
})
49 changes: 48 additions & 1 deletion packages/react-query/src/useMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TMutationFn extends (...args: Array<any>) => any> =
StripThrows<Awaited<ReturnType<TMutationFn>>>

type MutationFnVariables<TMutationFn extends (...args: Array<any>) => any> =
Parameters<TMutationFn> extends [] ? void : Parameters<TMutationFn>[0]

// HOOK

export function useMutation<
TMutationFn extends MutationFunction<any, any>,
TOnMutateResult = unknown,
>(
options: ThrowsFnOptions<
TMutationFn,
Omit<
UseMutationOptions<
MutationFnData<TMutationFn>,
InferErrorFromFn<TMutationFn>,
MutationFnVariables<TMutationFn>,
TOnMutateResult
>,
'mutationFn'
> & { mutationFn: TMutationFn }
>,
queryClient?: QueryClient,
): UseMutationResult<
MutationFnData<TMutationFn>,
InferErrorFromFn<TMutationFn>,
MutationFnVariables<TMutationFn>,
TOnMutateResult
>

export function useMutation<
TData = unknown,
TError = DefaultError,
TVariables = void,
TOnMutateResult = unknown,
>(
options: UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
queryClient?: QueryClient,
): UseMutationResult<TData, TError, TVariables, TOnMutateResult>

export function useMutation<
TData = unknown,
TError = DefaultError,
Expand Down
Loading