TypeScript with Zustand + TanStack Query: the types tutorials don't show you
Every tutorial shows the happy path. The real work starts when you split stores, add middleware, and try to keep TanStack Query keys honest.

Part 2 of the Zustand + TanStack Query series. The previous article showed the duo working. This one shows it working safely — the types that TypeScript demands when you move beyond the basic example and start building something real.
The article previously mentioned that Zustand has "excellent TypeScript support" — and it does. But the support being available doesn't mean the path there is obvious.
Most tutorials show this and stop:
const useStore = create<AppStore>((set) => ({ ... }));The problem starts when you try to type separate slices, combine stores, or use get inside a typed action. TypeScript starts complaining — and the error messages don't help you understand why.
Let's fix this from the start.
1. Typing slices with StateCreator
The previous article showed how to split a large store into slices. The code works in JavaScript — but in TypeScript, combining slices without the right type makes the compiler lose inference between them.
The compiler loses inference between slices, so cross-slice calls silently break type safety.
The compiler loses inference between slices, so the getter drops type safety and returns an untyped object.
The compiler loses inference between slices, so the combined AppStore union stops validating.
Combining Zustand slices without the right type makes the compiler lose inference between them.
The correct type for a slice is StateCreator:
import { create } from 'zustand';import { StateCreator } from 'zustand';// Tipos de domíniointerface User { id: string; name: string; email: string;}interface LoginCredentials { email: string; password: string;}// Tipos do slice de usuáriointerface UserSlice { user: User | null; isAuthenticated: boolean; login: (credentials: LoginCredentials) => Promise<void>; logout: () => void;}// Tipos do slice de UIinterface UISlice { theme: 'light' | 'dark'; sidebarOpen: boolean; toggleTheme: () => void; toggleSidebar: () => void;}// O tipo completo do storetype AppStore = UserSlice & UISlice;// StateCreator garante que cada slice conhece o store inteiroconst createUserSlice: StateCreator< AppStore, // tipo do store completo [], // middlewares (vazio por enquanto) [], UserSlice // tipo deste slice> = (set, get) => ({ user: null, isAuthenticated: false, login: async (credentials) => { const user = await authAPI.login(credentials); set({ user, isAuthenticated: true }); }, logout: () => { set({ user: null, isAuthenticated: false }); // get() aqui enxerga o AppStore inteiro — incluindo UISlice get().toggleSidebar(); },});// Combinando os slices — o TypeScript valida a unionconst useAppStore = create<AppStore>((...args) => ({ ...createUserSlice(...args), ...createUISlice(...args),}));2. Typing slices with middleware
The previous article used persist and devtools. When middleware enters, the second parameter of StateCreator changes — and this is where most people get stuck.
import { StateCreator } from 'zustand';// Com persist + devtools, o segundo parâmetro lista os middlewaresconst createUserSlice: StateCreator< AppStore, [ ['zustand/devtools', never], ['zustand/persist', Partial<AppStore>] ], [], UserSlice> = (set, get) => ({ ... });// O store combinado com middleware tipado corretamenteconst useAppStore = create<AppStore>()( devtools( persist( (...args) => ({ ...createUserSlice(...args), ...createUISlice(...args), }), { name: 'app-store' } ) ));3. useQuery with explicit generics
TanStack Query infers quite a lot automatically — but inference has limits. When the fetch function returns any, or when you need to type the error separately, explicit generics are the way.
useQuery accepts four generics: <TData, TError, TData, TQueryKey>. In practice, the first two are the most important:
import { useQuery } from '@tanstack/react-query';// Tipos de domíniointerface Product { id: number; name: string; price: number; category: string;}interface ApiError { message: string; statusCode: number;}// Sem generic explícito — error é unknownconst { data, error } = useQuery({ queryKey: ['products'], queryFn: fetchProducts, // retorna Promise<Product[]>});// error aqui é: Error | null — não ApiError// Com generics explícitos — error é ApiErrorconst { data, error } = useQuery<Product[], ApiError>({ queryKey: ['products'], queryFn: fetchProducts,});// Agora error.statusCode existe e o TypeScript sabe dissoif (error) { console.log(error.statusCode); // sem erro de tipo}4. Typed query key factories
One of the most silent problems with TanStack Query in TypeScript: query keys scattered throughout the codebase as loose strings. When you need to invalidate a query, it becomes impossible to guarantee the key is correct.
The solution is a factory object with as const:
useQuery({ queryKey: ['products'], ...});// Typo — não invalida nadaqueryClient.invalidateQueries( ['product']);// Typo vira erro em compile timeuseQuery({ queryKey: productKeys.list(filters), ...});queryClient.invalidateQueries({ queryKey: productKeys.all});interface ProductFilters { category: string | null; priceRange: [number, number]; search: string;}export const productKeys = { all: ['products'] as const, lists: () => [...productKeys.all, 'list'] as const, list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const, details: () => [...productKeys.all, 'detail'] as const, detail: (id: number) => [...productKeys.details(), id] as const,} as const;// Uso — o TypeScript valida cada keyuseQuery({ queryKey: productKeys.list({ category: 'electronics', priceRange: [0, 1000], search: '' }), queryFn: () => fetchProducts({ category: 'electronics', priceRange: [0, 1000], search: '' }),});// Invalidar todos os produtosqueryClient.invalidateQueries({ queryKey: productKeys.all });// Invalidar só o produto de id 42queryClient.invalidateQueries({ queryKey: productKeys.detail(42) });5. useMutation with all four generics
useMutation has four generics: <TData, TError, TVariables, TContext>. The TContext is what most people ignore — it's the value you return in onMutate and receive back in onError for rollback. Without typing it, it's unknown — and you lose type safety at the most critical point.
import { useMutation, useQueryClient } from '@tanstack/react-query';interface CreateProductInput { name: string; price: number; category: string;}interface MutationContext { previousProducts: Product[] | undefined;}function useCreateProduct() { const queryClient = useQueryClient(); return useMutation< Product, // TData — o que a API retorna ApiError, // TError — tipo do erro CreateProductInput, // TVariables — o que passa pra mutationFn MutationContext // TContext — o que onMutate retorna >({ mutationFn: (input) => createProduct(input), onMutate: async (newProduct) => { await queryClient.cancelQueries({ queryKey: productKeys.all }); const previousProducts = queryClient.getQueryData<Product[]>( productKeys.lists() ); queryClient.setQueryData<Product[]>(productKeys.lists(), (old) => old ? [...old, { ...newProduct, id: Date.now() }] : [] ); // TypeScript sabe que o retorno é MutationContext return { previousProducts }; }, onError: (error, variables, context) => { // context é MutationContext | undefined — TypeScript força o check if (context?.previousProducts) { queryClient.setQueryData( productKeys.lists(), context.previousProducts ); } console.error(`Erro ${error.statusCode}: ${error.message}`); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: productKeys.all }); }, });}6. Connecting Zustand and TanStack Query with types
The previous article showed Zustand filters feeding the useQuery. With TypeScript, this contract needs to be explicit — the filter type in the store must match the type the query accepts.
// O tipo dos filtros — definido uma vez, usado em dois lugaresinterface ProductFilters { category: string | null; priceRange: [number, number]; search: string;}// No store do Zustandinterface UISlice { filters: ProductFilters; // mesmo tipo updateFilters: (partial: Partial<ProductFilters>) => void;}const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set) => ({ filters: { category: null, priceRange: [0, 1000], search: '', }, updateFilters: (partial) => set((state) => ({ filters: { ...state.filters, ...partial } })),});// No hook de query — o tipo do filtro é inferido do storefunction useProducts() { const filters = useAppStore((state) => state.filters); // filters: ProductFilters — inferido automaticamente return useQuery<Product[], ApiError>({ queryKey: productKeys.list(filters), // bate o tipo queryFn: () => fetchProducts(filters), placeholderData: (prev) => prev, // keepPreviousData no v5 });}// Selectors tipados — evitam re-render desnecessárioconst category = useAppStore((state) => state.filters.category);// category: string | null — inferido automaticamenteDefining the filter type once and reusing it on both sides is more than organization — it's a contract. If the filter changes (adding a new field, for example), TypeScript points out all the places that need to change together.
7. The queryOptions pattern (TanStack Query v5)
If you're on TanStack Query v5, the queryOptions helper centralizes the query definition — types, key, and function — in one place. The result is that anywhere using the query inherits the types automatically, without needing to repeat the generics.
import { queryOptions } from '@tanstack/react-query';// Define a query uma vezconst productsQueryOptions = (filters: ProductFilters) => queryOptions({ queryKey: productKeys.list(filters), queryFn: () => fetchProducts(filters), staleTime: 5 * 60 * 1000, });// O tipo de retorno é inferido de fetchProducts automaticamente// Usa em componentes — sem repetir os genericsfunction useProducts(filters: ProductFilters) { return useQuery(productsQueryOptions(filters));}// Prefetch com o mesmo objeto — consistência garantidaasync function prefetchProducts(filters: ProductFilters) { await queryClient.prefetchQuery(productsQueryOptions(filters));}// Acesso direto ao cache — também tipadoconst cached = queryClient.getQueryData( productsQueryOptions(filters).queryKey);// cached: Product[] | undefined

