TypeScript Guide
TanStack Query is written in TypeScript and provides excellent type safety out of the box.
Type Inference
TanStack Query infers types automatically from your query functions:
const { data } = useQuery ({
queryKey: [ 'repo' ],
queryFn : async () => {
const response = await fetch ( 'https://api.github.com/repos/TanStack/query' )
return response . json () as Repository
},
})
// data is automatically typed as Repository | undefined
Use type assertions in your query function to inform TypeScript about the returned data shape.
Explicit Type Parameters
For more control, specify types explicitly:
interface Repository {
name : string
description : string
stargazers_count : number
forks_count : number
}
const { data , error } = useQuery <
Repository , // TQueryFnData - Type returned by queryFn
Error , // TError - Type of errors
Repository , // TData - Type of data (after select)
[ 'repo' ] // TQueryKey - Type of queryKey
> ({
queryKey: [ 'repo' ],
queryFn : async () => {
const response = await fetch ( 'https://api.github.com/repos/TanStack/query' )
return response . json ()
},
})
// data is Repository | undefined
// error is Error | null
In most cases, you only need to specify the first type parameter (TQueryFnData). The others can be inferred.
Typing Query Keys
Strong typing for query keys helps prevent errors:
type QueryKey =
| [ 'todos' ]
| [ 'todo' , number ]
| [ 'user' , string , { includeProjects : boolean }]
const { data } = useQuery < Todo , Error , Todo , [ 'todo' , number ]>({
queryKey: [ 'todo' , todoId ],
queryFn : ({ queryKey }) => {
const [ _key , id ] = queryKey // id is typed as number
return fetchTodo ( id )
},
})
Using queryOptions Helper
The queryOptions helper provides better type inference:
import { queryOptions , useQuery } from '@tanstack/react-query'
interface Post {
id : number
title : string
body : string
}
function postOptions ( postId : number ) {
return queryOptions ({
queryKey: [ 'post' , postId ],
queryFn : async () : Promise < Post > => {
const response = await fetch ( `/api/posts/ ${ postId } ` )
return response . json ()
},
staleTime: 1000 * 60 * 5 ,
})
}
// Fully typed, reusable query options
function Post ({ postId } : { postId : number }) {
const { data } = useQuery ( postOptions ( postId ))
// data is typed as Post | undefined
return < div > { data ?. title } </ div >
}
Use queryOptions to create reusable, well-typed query configurations that can be shared across components.
Typing Mutations
Mutations support full type safety for variables, data, and errors:
import { useMutation } from '@tanstack/react-query'
interface Todo {
id : number
title : string
}
interface CreateTodoVariables {
title : string
}
function TodoForm () {
const mutation = useMutation <
Todo , // TData - Mutation response type
Error , // TError - Error type
CreateTodoVariables , // TVariables - Variables type
{ previousTodos ?: Todo [] } // TContext - Context type
> ({
mutationFn : async ( variables ) => {
// variables is typed as CreateTodoVariables
const response = await fetch ( '/api/todos' , {
method: 'POST' ,
body: JSON . stringify ( variables ),
})
return response . json ()
},
onSuccess : ( data , variables , context ) => {
// data is typed as Todo
// variables is typed as CreateTodoVariables
// context is typed as { previousTodos?: Todo[] }
console . log ( 'Created:' , data . title )
},
})
return (
< button
onClick = { () => {
mutation . mutate ({ title: 'New Todo' })
} }
>
Add Todo
</ button >
)
}
Typing the QueryClient
The QueryClient is fully typed and provides type-safe methods:
import { QueryClient , useQueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient ()
function Component () {
const client = useQueryClient ()
// Type-safe query data access
const todos = client . getQueryData < Todo []>([ 'todos' ])
// todos is Todo[] | undefined
// Type-safe query data updates
client . setQueryData < Todo []>([ 'todos' ], ( old ) => {
// old is Todo[] | undefined
return old ? [ ... old , newTodo ] : [ newTodo ]
})
}
Typing Query Results with Initial Data
When providing initial data, the result type changes:
const { data } = useQuery ({
queryKey: [ 'todos' ],
queryFn: fetchTodos ,
initialData: [] as Todo [],
})
// data is Todo[] (not Todo[] | undefined)
// Because initial data is always present
With initialData, the data property is never undefined, making it easier to work with.
Type-Safe Select
Transform query data with full type safety:
interface Todo {
id : number
title : string
completed : boolean
}
const { data } = useQuery ({
queryKey: [ 'todos' ],
queryFn : async () : Promise < Todo []> => {
const response = await fetch ( '/api/todos' )
return response . json ()
},
select : ( todos ) => {
// todos is typed as Todo[]
return todos . filter (( todo ) => ! todo . completed )
},
})
// data is typed as Todo[] | undefined
Discriminated Unions
Use TypeScript’s discriminated unions with query status:
const result = useQuery ({
queryKey: [ 'todos' ],
queryFn: fetchTodos ,
})
if ( result . status === 'pending' ) {
// TypeScript knows data is undefined here
return < Spinner />
}
if ( result . status === 'error' ) {
// TypeScript knows error is defined here
return < div > Error: { result . error . message } </ div >
}
// TypeScript knows data is defined here
return < div > { result . data . length } todos </ div >
Generic Query Hook
Create reusable typed query hooks:
import { useQuery , UseQueryOptions , UseQueryResult } from '@tanstack/react-query'
function useTypedQuery < TData , TError = Error >(
key : unknown [],
fetcher : () => Promise < TData >,
options ?: Omit < UseQueryOptions < TData , TError >, 'queryKey' | 'queryFn' >
) : UseQueryResult < TData , TError > {
return useQuery < TData , TError >({
queryKey: key ,
queryFn: fetcher ,
... options ,
})
}
// Usage
interface User {
id : string
name : string
}
function useUser ( userId : string ) {
return useTypedQuery < User >(
[ 'user' , userId ],
() => fetchUser ( userId ),
{ staleTime: 1000 * 60 * 5 }
)
}
Infinite Queries
Infinite queries have special type requirements:
import { useInfiniteQuery } from '@tanstack/react-query'
interface Page {
data : Todo []
nextCursor : number | null
}
const { data , fetchNextPage , hasNextPage } = useInfiniteQuery <
Page , // TQueryFnData - Single page type
Error ,
Page , // TData - After select
[ 'todos' ],
number // TPageParam - Page param type
> ({
queryKey: [ 'todos' ],
queryFn : async ({ pageParam = 0 }) => {
// pageParam is typed as number
const response = await fetch ( `/api/todos?cursor= ${ pageParam } ` )
return response . json ()
},
getNextPageParam : ( lastPage ) => lastPage . nextCursor ,
initialPageParam: 0 ,
})
// data.pages is Page[]
Always provide initialPageParam when using useInfiniteQuery to ensure proper typing.
Best Practices
Define interfaces for your data
Create TypeScript interfaces for all API responses: interface User {
id : string
name : string
email : string
}
Use queryOptions for reusability
Create typed, reusable query configurations: const userOptions = ( id : string ) => queryOptions ({
queryKey: [ 'user' , id ],
queryFn : () => fetchUser ( id ),
})
Let TypeScript infer when possible
Avoid over-specifying types. Let TypeScript infer from your query functions.
Use discriminated unions
Take advantage of status checks for better type narrowing.
Common Type Errors
”Type ‘undefined’ is not assignable to type…”
This happens when you forget that data can be undefined:
// ❌ Wrong
const { data } = useQuery ({ queryKey: [ 'user' ], queryFn: fetchUser })
return < div > { data . name } </ div > // Error: data might be undefined
// ✅ Correct
const { data } = useQuery ({ queryKey: [ 'user' ], queryFn: fetchUser })
if ( ! data ) return null
return < div > { data . name } </ div >
// ✅ Also correct with optional chaining
const { data } = useQuery ({ queryKey: [ 'user' ], queryFn: fetchUser })
return < div > { data ?. name } </ div >
Use the status field or provide initialData to avoid undefined checks.
Next Steps
DevTools Set up DevTools to inspect your typed queries
Essential Concepts Review core concepts with TypeScript examples