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
5 changes: 5 additions & 0 deletions .changeset/quiet-cooks-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-core': minor
---

Add framework-agnostic `queryOptions` and `mutationOptions` helpers.
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import { queryKey } from '@tanstack/query-test-utils'
import { QueryClient } from '@tanstack/query-core'
import {
QueryClient,
mutationOptions as coreMutationOptions,
} from '@tanstack/query-core'
import {
injectIsMutating,
injectMutation,
injectMutationState,
mutationOptions,
} from '..'
import type { Signal } from '@angular/core'
import type {
DefaultError,
MutationFunctionContext,
Expand Down Expand Up @@ -174,6 +178,20 @@ describe('mutationOptions', () => {
)
})

it('should infer types when core mutationOptions are used with injectMutation', () => {
const mutation = injectMutation(() =>
coreMutationOptions({
mutationFn: (input: { id: string }) => Promise.resolve(input.id),
}),
)

expectTypeOf(mutation.data).toEqualTypeOf<Signal<string | undefined>>()
expectTypeOf(mutation.variables).toEqualTypeOf<
Signal<{ id: string } | undefined>
>()
expectTypeOf(mutation.mutate).toBeCallableWith({ id: '1' })
})

it('should infer types when used with injectIsMutating', () => {
const key = queryKey()
const isMutating = injectIsMutating(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import { queryKey } from '@tanstack/query-test-utils'
import { QueryClient, dataTagSymbol, injectQuery, queryOptions } from '..'
import {
QueryClient,
queryOptions as coreQueryOptions,
dataTagSymbol,
} from '@tanstack/query-core'
import { injectQuery, queryOptions } from '..'
import type { Signal } from '@angular/core'

describe('queryOptions', () => {
it('should not allow excess properties', () => {
expectTypeOf(queryOptions).parameter(0).not.toHaveProperty('stallTime')
expectTypeOf(queryOptions)
.parameter(0)
.not.toHaveProperty('stallTime')
})

it('should infer types for callbacks', () => {
Expand Down Expand Up @@ -34,9 +41,9 @@ describe('queryOptions', () => {
!id
? undefined
: {
id,
title: 'Initial Data',
},
id,
title: 'Initial Data',
},
})

expectTypeOf(options(null).initialData).returns.toEqualTypeOf<
Expand All @@ -56,6 +63,19 @@ it('should work when passed to injectQuery', () => {
expectTypeOf(data).toEqualTypeOf<Signal<number | undefined>>()
})

it('should work when core queryOptions are passed to injectQuery', () => {
const key = queryKey()
const options = coreQueryOptions({
queryKey: key,
queryFn: () => Promise.resolve({ id: '1', title: 'Do Laundry' }),
})

const { data } = injectQuery(() => options)
expectTypeOf(data).toEqualTypeOf<
Signal<{ id: string; title: string } | undefined>
>()
})

it('should work when passed to fetchQuery', () => {
const key = queryKey()
const options = queryOptions({
Expand Down
26 changes: 26 additions & 0 deletions packages/lit-query/src/tests/type-inference.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
dataTagSymbol,
mutationOptions as coreMutationOptions,
QueryClient,
queryOptions as coreQueryOptions,
type DefinedQueryObserverResult,
type QueryObserverResult,
} from '@tanstack/query-core'
Expand Down Expand Up @@ -161,6 +163,18 @@ describe('type inference', () => {
{ id: number; name: string } | undefined
>()

const coreQuery = createQueryController(
host,
() => coreQueryOptions({
queryKey: ['type-inference', 'core-query'] as const,
queryFn: async () => ({ id: '1', title: 'Do Laundry' }),
}),
client,
)
expectTypeOf(coreQuery().data).toEqualTypeOf<
{ id: string; title: string } | undefined
>()

const mutation = createMutationController(
host,
mutationOptions({
Expand All @@ -173,6 +187,18 @@ describe('type inference', () => {
{ id: number } | undefined
>()

const coreMutation = createMutationController(
host,
() => coreMutationOptions({
mutationFn: async (input: { id: string }) => input.id,
}),
client,
)
expectTypeOf(coreMutation().data).toEqualTypeOf<string | undefined>()
expectTypeOf(coreMutation().variables).toEqualTypeOf<
{ id: string } | undefined
>()

const queryOpts = queryOptions({
queryKey: ['type-inference', 'query-options'] as const,
queryFn: async () => ({ id: 2, name: 'Grace' }),
Expand Down
19 changes: 18 additions & 1 deletion packages/preact-query/src/__tests__/mutationOptions.test-d.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { QueryClient } from '@tanstack/query-core'
import {
QueryClient,
mutationOptions as coreMutationOptions,
} from '@tanstack/query-core'
import type {
DefaultError,
MutationFunctionContext,
Expand Down Expand Up @@ -162,6 +165,20 @@ describe('mutationOptions', () => {
)
})

it('should infer types when core mutationOptions are used with useMutation', () => {
const mutation = useMutation(
coreMutationOptions({
mutationFn: (input: { id: string }) => Promise.resolve(input.id),
}),
)

expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
expectTypeOf(mutation.variables).toEqualTypeOf<
{ id: string } | undefined
>()
expectTypeOf(mutation.mutate).toBeCallableWith({ id: '1' })
})

it('should infer types when used with useIsMutating', () => {
const isMutating = useIsMutating(
mutationOptions({
Expand Down
12 changes: 12 additions & 0 deletions packages/preact-query/src/__tests__/queryOptions.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
QueriesObserver,
QueryClient,
dataTagSymbol,
queryOptions as coreQueryOptions,
skipToken,
} from '@tanstack/query-core'
import type {
Expand Down Expand Up @@ -48,6 +49,17 @@ describe('queryOptions', () => {
const { data } = useQuery(options)
expectTypeOf(data).toEqualTypeOf<number | undefined>()
})
it('should work when core queryOptions are passed to useQuery', () => {
const options = coreQueryOptions({
queryKey: queryKey(),
queryFn: () => Promise.resolve({ id: '1', title: 'Do Laundry' }),
})

const { data } = useQuery(options)
expectTypeOf(data).toEqualTypeOf<
{ id: string; title: string } | undefined
>()
})
it('should work when passed to useSuspenseQuery', () => {
const options = queryOptions({
queryKey: queryKey(),
Expand Down
47 changes: 47 additions & 0 deletions packages/query-core/src/__tests__/mutationOptions.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expectTypeOf, it } from 'vitest'
import { mutationOptions } from '../mutationOptions'
import type { CoreMutationOptions } from '../index'

describe('mutationOptions', () => {
it('should infer mutation data and variables', () => {
const options = mutationOptions({
mutationFn: (variables: { id: string }) => Promise.resolve(variables.id),
onSuccess: (data, variables) => {
expectTypeOf(data).toEqualTypeOf<string>()
expectTypeOf(variables).toEqualTypeOf<{ id: string }>()
},
})

expectTypeOf(options.mutationFn)
.parameter(0)
.toEqualTypeOf<{ id: string }>()
type MutationFn = NonNullable<typeof options.mutationFn>
expectTypeOf<Awaited<ReturnType<MutationFn>>>().toEqualTypeOf<string>()
expectTypeOf(options).not.toHaveProperty('mutationKey')
})

it('should preserve a required mutationKey', () => {
const options = mutationOptions({
mutationKey: ['key'],
mutationFn: (variables: { id: string }) => Promise.resolve(variables.id),
})

expectTypeOf(options.mutationKey).toEqualTypeOf<ReadonlyArray<unknown>>()
})

it('should export the reusable mutation options type', () => {
expectTypeOf<
CoreMutationOptions<string, Error, { id: string }>
>().toHaveProperty('mutationFn')
})

it('should not allow _defaulted', () => {
expectTypeOf(mutationOptions).parameter(0).not.toHaveProperty('_defaulted')

mutationOptions({
mutationFn: () => Promise.resolve(5),
// @ts-expect-error _defaulted is an internal option
_defaulted: true,
})
})
})
102 changes: 102 additions & 0 deletions packages/query-core/src/__tests__/queryOptions.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expectTypeOf, it } from 'vitest'
import { dataTagErrorSymbol, dataTagSymbol } from '../types'
import { skipToken } from '../utils'
import { QueryClient } from '../queryClient'
import { queryOptions } from '../queryOptions'

describe('queryOptions', () => {
it('should infer query data from queryFn', () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
})
type QueryFn = Exclude<typeof options.queryFn, typeof skipToken | undefined>

expectTypeOf<Awaited<ReturnType<QueryFn>>>().toEqualTypeOf<number>()
expectTypeOf(options.queryKey[dataTagSymbol]).toEqualTypeOf<number>()
expectTypeOf(options.queryKey[dataTagErrorSymbol]).toEqualTypeOf<Error>()
})

it('should tag queryKey with the query data type', () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
})
const queryClient = new QueryClient()
const data = queryClient.getQueryData(options.queryKey)

expectTypeOf(data).toEqualTypeOf<number | undefined>()
})

it('should type check values passed to setQueryData from the tagged queryKey', () => {
const options = queryOptions({
queryKey: ['todo'],
queryFn: () => Promise.resolve({ id: '1', title: 'Do Laundry' }),
})
const queryClient = new QueryClient()

queryClient.setQueryData(options.queryKey, {
id: '1',
title: 'Wash dishes',
})

queryClient.setQueryData(options.queryKey, (prev) => {
expectTypeOf(prev).toEqualTypeOf<
{ id: string; title: string } | undefined
>()
return prev
})

// @ts-expect-error title should be a string
queryClient.setQueryData(options.queryKey, { id: '1', title: 1 })

// @ts-expect-error title is required
queryClient.setQueryData(options.queryKey, { id: '1' })
})

it('should allow initialData without a queryFn', () => {
const options = queryOptions({
queryKey: ['key'],
initialData: 1,
})
const queryClient = new QueryClient()
const data = queryClient.getQueryData(options.queryKey)

expectTypeOf(options.queryKey[dataTagSymbol]).toEqualTypeOf<number>()
expectTypeOf(data).toEqualTypeOf<number | undefined>()
})

it('should infer data when initialData is undefined', () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
initialData: undefined,
})

expectTypeOf(options.initialData).toEqualTypeOf<undefined>()
expectTypeOf(options.queryKey[dataTagSymbol]).toEqualTypeOf<number>()
expectTypeOf(options.queryKey[dataTagErrorSymbol]).toEqualTypeOf<Error>()
})

it('should infer a callable queryFn without skipToken', () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
enabled: false,
})
type QueryFn = Exclude<typeof options.queryFn, undefined>

expectTypeOf<QueryFn>().not.toEqualTypeOf<typeof skipToken>()
expectTypeOf<Awaited<ReturnType<QueryFn>>>().toEqualTypeOf<number>()
expectTypeOf(options.queryKey[dataTagSymbol]).toEqualTypeOf<number>()
})

it('should allow skipToken', () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: skipToken,
})

expectTypeOf(options.queryKey[dataTagSymbol]).toEqualTypeOf<unknown>()
})
})
4 changes: 4 additions & 0 deletions packages/query-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export {
export type { MutationFilters, QueryFilters, SkipToken, Updater } from './utils'

export { streamedQuery as experimental_streamedQuery } from './streamedQuery'
export { queryOptions } from './queryOptions'
export type { CoreQueryOptions } from './queryOptions'
export { mutationOptions } from './mutationOptions'
export type { CoreMutationOptions } from './mutationOptions'

// Types
export type {
Expand Down
Loading