Learning Zustand - TypeScript & Typing Stores
Episode 7 of 23

Learning Zustand - TypeScript & Typing Stores

This episode covers writing typed stores with create<State>()(...), defining state and action interfaces, typing middleware, using StoreApi, and the combine pattern for slicing large stores without losing types.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Zustand is written in TypeScript and provides excellent end-to-end typing. Episode 7 covers how to write typed stores: the curried create<State>()(...) pattern you must use, defining state and action interfaces, typing middleware, and combine for composing large stores from several parts.

With correct typing, errors in the store are caught at compile time instead of at runtime. This is the shield you need in a real codebase.

Typed Stores with create and Generics

The Curried Pattern

In TypeScript, the create call is written in a curried form so type inference works correctly:

JScreate<State>() with generics
import { create } from 'zustand'
 
interface CounterState {
  count: number
  increment: () => void
}
 
export const useCounter = create<CounterState>()((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}))

Notice the double parentheses: create<CounterState>()((set) => ...). The first parentheses receive the generic, the second receive the initializer. Without the double parentheses, TypeScript often fails to infer action types.

State and Action Interfaces

Define the entire shape of the state in a single interface:

JSState and actions interface
interface UserState {
  user: User | null
  status: 'idle' | 'loading' | 'success' | 'error'
  fetchUser: (id: string) => Promise<void>
  logout: () => void
}

interface UserState covers data and functions. Zustand will make sure the object returned by the initializer exactly matches the interface — missing properties or wrongly typed ones are rejected by the compiler immediately.

Using a Typed Store in a Component

Selectors are now correctly inferred:

JSTyped selectors
const count = useCounter((s) => s.count) // number
const increment = useCounter((s) => s.increment) // () => void
 
increment()

useCounter((s) => s.count) returns number, and useCounter((s) => s.increment) returns a function. Your editor will give you autocomplete and clear errors when a selector or action is misused.

Advanced Patterns

Typing Middleware

Middleware has its own typing. The curried pattern still applies:

JSTyping the persist middleware
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
 
interface ThemeState {
  theme: 'light' | 'dark'
  toggle: () => void
}
 
export const useTheme = create<ThemeState>()(
  persist(
    (set) => ({
      theme: 'light',
      toggle: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
    }),
    { name: 'theme-storage' },
  ),
)

persist(...) wraps the initializer, and create<ThemeState>()(...) still guarantees the shape of the state. Middleware typing like immer and devtools follows the same pattern.

StoreApi

The StoreApi type describes the full store API: getState, setState, subscribe, and getInitialState. It's useful for typing functions that accept a store as an argument:

JSThe StoreApi type
import type { StoreApi } from 'zustand'
 
function resetStore(store: StoreApi<CounterState>) {
  store.setState({ count: 0 })
}

StoreApi<CounterState> lets the resetStore function accept any store with the same type. This pattern is common in helpers and test utilities.

combine for Slicing

combine merges several state slices while automatically inferring the combined type:

JScombine for state slicing
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
 
export const useDashboard = create(
  combine({ user: null as User | null, theme: 'light' as 'light' | 'dark' },
    (set) => ({
      login: (user: User) => set({ user }),
      toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
    })),
)

combine(initialState, (set) => ...) merges data state and actions, and TypeScript infers the combined type without writing a manual interface. The choice between combine and an explicit interface is a team preference — for large slicing we use the Slice Pattern in episode 16.

Typing Best Practices

Enable strict in tsconfig

Make sure tsconfig.json uses strict mode:

Strict tsconfig
{
  "compilerOptions": {
    "strict": true
  }
}

strict: true in tsconfig activates all strict checks. With this, types like unknown from an error in catch can't be used directly without an assertion.

Closing

Episode 7 makes your stores type-safe: the curried create<State>()(...) pattern, state and action interfaces, middleware typing, StoreApi for helpers, and combine for slicing without writing manual types.

Key takeaways:

  • Use create<State>()(...) with double parentheses in TypeScript.
  • Define an interface for the entire state including actions.
  • Middleware typing follows the same curried pattern.
  • StoreApi<T> types function parameters that receive a store.
  • combine merges state and actions with automatic inference.
  • Enable strict in tsconfig for maximum protection.

In the next episode we dive into the persist middleware — saving state to localStorage automatically, the name and partialize options for choosing the stored subset, and skipHydration for full control of the rehydration process. Your state will survive after the browser is closed.