Typing Redux Actions and Reducers in TypeScript: A Practical Guide
Introduction
Redux is a predictable state container that powers many complex front-end applications. As applications grow, untyped actions and reducers can become a source of runtime bugs, poor DX, and fragile refactors. TypeScript helps dramatically: it enables compile-time guarantees about action payloads, reducer branches, and selectors. This guide targets intermediate developers who already know Redux basics and TypeScript fundamentals and want practical, production-ready patterns for typing actions and reducers.
In this article you'll learn how to: design action type systems, create type-safe action creators, type reducers (including combineReducers and generics), type async actions (thunks), and integrate Redux Toolkit while preserving type safety. We'll cover discriminated unions, ReturnType-based helpers, helper utilities for DRY typing, testing tips, migration strategies, and how to avoid common TypeScript pitfalls. There are lots of code examples, step-by-step instructions, troubleshooting guidance, and references to related TypeScript topics for deeper reading.
By the end you'll be able to write Redux code where the compiler helps you maintain correctness across actions, reducers, and consumers. You will also get tips on configuring TypeScript and tooling to catch mistakes early, and links to related topics like typing callbacks, handling async code, and organizing TypeScript projects for maintainability.
Background & Context
Redux actions are the events that describe state changes; reducers are pure functions that apply those actions to produce new state. Without types, action shapes are ad-hoc and fragile. With TypeScript, we can express each action's shape precisely and use discriminated unions so reducers exhaustively handle every action kind. Well-typed actions enable better refactors, safer component-level dispatching, and enhanced IDE auto-complete.
This guide focuses on the practical intersection of Redux and TypeScript: how to structure action types, action creators, and reducer signatures so you get strong type-safety without verbose boilerplate. We'll emphasize patterns that scale well, integrate with middleware like thunks, and work smoothly with modern libraries such as Redux Toolkit.
If you need to tighten compiler rules, consider using recommended flags to harden TypeScript behavior—see our guide on Recommended tsconfig.json Strictness Flags for New Projects for configuration tips.
Key Takeaways
- Use discriminated unions for actions to get exhaustive reducer checks.
- Prefer typed action creators and helper utilities to avoid duplication.
- Type reducers with State and Action generics; use ReturnType and utility types to infer action types from creators.
- For async flows, type thunks and middleware correctly to preserve state and dispatch types.
- Integrate immutability patterns (Readonly or libraries) to avoid accidental mutations.
- Use clear naming conventions and file organization for scalable code.
Prerequisites & Setup
What you'll need:
- Familiarity with TypeScript basics (types, interfaces, generics).
- Working knowledge of Redux (store, dispatch, reducers, actions).
- Node.js and package manager (npm/yarn). Install Redux and TypeScript packages:
npm install redux react-redux @types/react-redux typescript.
If you're introducing stricter typing to a project, read the tsconfig suggestions in Recommended tsconfig.json Strictness Flags for New Projects to enable safer defaults. Also consider migration patterns if moving from JS to TS; our migration guide is a helpful companion for large codebases.
Main Tutorial Sections
1) Action Types: string constants vs literal types
Start by declaring action type constants. Using string literal types ensures strong typing everywhere:
export const ADD_TODO = 'todos/add' as const export const REMOVE_TODO = 'todos/remove' as const type AddTodoType = typeof ADD_TODO
Alternatively, use an enum but string literals are friendlier for serializing and debugging. Use consistent naming per our recommendations in Naming Conventions in TypeScript (Types, Interfaces, Variables) to keep types discoverable.
2) Action Interfaces and Discriminated Unions
Define action interfaces with a common discriminant property type:
interface AddTodoAction { type: typeof ADD_TODO; payload: { id: string; text: string } }
interface RemoveTodoAction { type: typeof REMOVE_TODO; payload: { id: string } }
type TodoAction = AddTodoAction | RemoveTodoActionDiscriminated unions allow exhaustive switch statements in reducers. The TypeScript compiler will warn when an action case is unhandled.
3) Typed Action Creators and Helper Utilities
Write concise typed creators and derive the action type from them using ReturnType:
export const addTodo = (id: string, text: string) => ({ type: ADD_TODO, payload: { id, text } } as const)
export const removeTodo = (id: string) => ({ type: REMOVE_TODO, payload: { id } } as const)
export type TodoActions = ReturnType<typeof addTodo> | ReturnType<typeof removeTodo>Using as const keeps the type a literal, and ReturnType avoids duplicating types. This approach reduces maintenance burden.
For more patterns on typing callback shapes used in your action creators or middleware hooks, our guide on Typing Callbacks in TypeScript: Patterns, Examples, and Best Practices is useful.
4) Typing Reducers and the Reducer Signature
Reducers should be typed to accept State and Action generics:
type TodosState = { byId: Record<string, { id: string; text: string }>; allIds: string[] }
function todosReducer(state: TodosState = initialState, action: TodoActions): TodosState {
switch (action.type) {
case ADD_TODO:
// action.payload typed correctly
return { /* ... */ }
default:
return state
}
}Use exhaustive checks with a never helper to ensure future action types are handled:
function assertNever(x: never): never { throw new Error('Unexpected action: ' + (x as any).type) }
// in switch default: return assertNever(action)If you see errors like "Property 'x' does not exist on type 'Y'", refer to Property 'x' does not exist on type 'Y' Error: Diagnosis and Fixes to diagnose missing payload properties.
5) Combining Reducers and Inferred Root Action Types
When using combineReducers, infer the root state and action types carefully:
import { combineReducers } from 'redux'
const rootReducer = combineReducers({ todos: todosReducer /*, users: usersReducer */ })
export type RootState = ReturnType<typeof rootReducer>For actions, there's no built-in ReturnType; collect per-slice action unions and create a RootAction union:
type RootAction = TodosActions | UsersActions
Organize files to export slice-specific types; see guidance on Organizing Your TypeScript Code: Files, Modules, and Namespaces for module layout patterns.
6) Typing Thunks and Async Actions
For async flows, type thunk actions so dispatch and getState are typed:
import { ThunkAction } from 'redux-thunk'
type AppThunk<Return = void> = ThunkAction<Return, RootState, unknown, RootAction>
export const fetchTodos = (): AppThunk => async (dispatch, getState) => {
dispatch({ type: 'todos/fetchStart' })
const res = await fetch('/api/todos')
const data = await res.json()
dispatch({ type: 'todos/fetchSuccess', payload: data })
}Typing async code well is critical; for patterns and pitfalls with Promises and async/await, read Typing Asynchronous JavaScript: Promises and Async/Await.
7) Using Redux Toolkit with Strong Typing
Redux Toolkit reduces boilerplate and provides typed helpers. A typed slice example:
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: initialState as TodosState,
reducers: {
addTodo(state, action: PayloadAction<{ id: string; text: string }>) {
// immer-powered mutation allowed
state.byId[action.payload.id] = action.payload
state.allIds.push(action.payload.id)
}
}
})
export const { addTodo: addTodoRTK } = todosSlice.actions
export default todosSlice.reducerRedux Toolkit uses immer under the hood. If you're weighing immutability strategies, consult Using Readonly vs. Immutability Libraries in TypeScript to decide when to use readonly types vs libraries like immer.
8) Infering Action Types from Creators and Avoiding Duplication
Avoid duplicating action types by deriving types from creators. Example utilities:
type ActionFromCreator<T> = T extends (...args: any[]) => infer R ? R : never type TodosActions = ActionFromCreator<typeof addTodo> | ActionFromCreator<typeof removeTodo>
For large apps, centralize action factories and export typed unions. This reduces mismatch risk between creators and reducer expectations.
9) Typing mapDispatchToProps, Hooks, and Connected Components
For React-Redux integration using hooks, type dispatch so components get proper autocompletion:
import { useDispatch } from 'react-redux'
import type { ThunkDispatch } from 'redux-thunk'
type AppDispatch = ThunkDispatch<RootState, unknown, RootAction>
export const useAppDispatch = () => useDispatch<AppDispatch>()
// in component:
// const dispatch = useAppDispatch()
// dispatch(addTodo('id', 'text'))When using older connect HOCs, type mapDispatchToProps generics carefully to avoid the "This expression is not callable" error; consult our troubleshooting article on Fixing the "This expression is not callable" Error in TypeScript when encountering function type mismatches.
Advanced Techniques
Once you have the basics, adopt these expert patterns: use discriminated unions for every action; derive action unions from creators to avoid duplication; create generic reducer factories that accept a mapping of handlers to reduce boilerplate; type selector return values using ReturnType to guarantee consistency with slice reducers. Consider using branded types to avoid accidental mixing of IDs (e.g., type UserId = string & { readonly brand: unique symbol }).
For performance, avoid over-reliance on heavy generic inference in hot paths where compile-time complexity could slow tooling. Instead, favor explicit types on public API boundaries (action creators, slice exports) and let inference do the rest. Use readonly where appropriate to prevent mutations in tests and dev builds. For a deeper walkthrough of common compiler issues you might face, check Common TypeScript Compiler Errors Explained and Fixed.
Best Practices & Common Pitfalls
Dos:
- Do use discriminated unions for exhaustive reducer checks.
- Do derive action types from creators (ReturnType) to avoid drift.
- Do type thunks and middleware so dispatch returns are consistent.
- Do centralize state and action exports from slice modules and follow consistent naming (see Naming Conventions in TypeScript (Types, Interfaces, Variables)).
Don'ts:
- Don't rely on any or loose
objecttypes for actions — they remove compiler checks. - Don't mutate state outside of immer in RTK slices.
- Avoid large, monolithic action unions when slice-local unions suffice.
Troubleshooting tips:
- If payload properties are "missing" in the editor, check your action type discriminant: literal vs string. Use
as constor a const value fortypeto preserve the literal. - If you get strange inference failures in complex generics, simplify the public API by making types explicit on exported functions.
Refer to Property 'x' does not exist on type 'Y' Error: Diagnosis and Fixes if you encounter property access errors on action payloads.
Real-World Applications
These patterns apply directly to production apps: large multi-team Redux stores, micro-frontend architectures with shared state conventions, or apps migrating from JS to TS. For example, when building features that rely on optimistic updates, typed actions ensure rollback payloads and success/failure actions carry expected properties. For background sync tasks that involve async flows, typed thunks ensure getState and dispatch types remain accurate across middleware.
When planning large refactors, combine the typing patterns here with projects' coding standards and file organization—see Organizing Your TypeScript Code: Files, Modules, and Namespaces to keep slice files discoverable and tests aligned.
Conclusion & Next Steps
Typing Redux actions and reducers in TypeScript brings huge benefits: safer refactors, fewer runtime bugs, and better IDE experience. Start by adopting discriminated unions and typed action creators, then type reducers and thunks. Migrate gradually and centralize slice types to keep drift low. Next, explore Redux Toolkit for ergonomic patterns and read related TypeScript topics linked throughout this article to round out your knowledge.
Recommended next steps:
- Apply these patterns to a small feature branch.
- Add stricter tsconfig flags and iterate (see the tsconfig guide linked earlier).
- Practice typing async flows and middleware in a sandbox.
Enhanced FAQ Section
Q1: Why use discriminated unions for actions?
A1: Discriminated unions give you a compile-time check that reducers handle every action case. A common pattern is to use type as the discriminant. When you switch on action.type, TypeScript narrows the action type and exposes the correct payload. This prevents runtime errors due to missing payload properties and makes refactors safer.
Q2: How should I name action types and creators?
A2: Use clear, scoped names like todos/add, users/loginSuccess. This prevents naming collisions across slices. Follow consistent naming conventions for types, interfaces, and variables—our Naming Conventions piece offers patterns to keep your naming predictive and readable.
Q3: Should I use enums or string literals for action types?
A3: String literal constants (with as const) are recommended: they're easier to serialize, show up nicely in Redux DevTools, and interoperate well with external systems. Enums are fine but can be more verbose and sometimes complicate interop with external JSON payloads.
Q4: How do I avoid duplicating types between action creators and reducer cases?
A4: Use ReturnType<typeof creator> or small utility types to derive action types from creators. This keeps a single source of truth (the creator) and reduces maintenance burden. Example: type TodoAction = ReturnType<typeof addTodo> | ReturnType<typeof removeTodo>.
Q5: How do I type async thunks so they work with dispatch and getState?
A5: Use the ThunkAction/ThunkDispatch types from redux-thunk or create your own AppThunk typed alias. Provide RootState and RootAction as generics to get accurate typing for both dispatch and getState.
Q6: How do I handle middleware that augments dispatch (like redux-thunk or custom middleware)?
A6: Type your AppDispatch to match the dispatcher shape you expose to components. For thunks, you can use ThunkDispatch<RootState, unknown, RootAction> and expose a useAppDispatch hook that returns that type. This ensures components can dispatch async thunks without type errors.
Q7: What are common TypeScript errors when typing Redux and how to fix them? A7: Common issues include payload property errors, inference failing for complex union types, and "This expression is not callable" when passing incorrectly typed functions. Many of these show up in general TypeScript compiler error lists; consult Common TypeScript Compiler Errors Explained and Fixed for generic fixes. For callable expression issues specifically, see Fixing the "This expression is not callable" Error in TypeScript.
Q8: Should I use immutability libraries or readonly types for state?
A8: Both are valid. Redux Toolkit uses immer to allow mutation-style code while keeping immutability. For library-free guarantees, Readonly and readonly collections prevent accidental mutations at compile time. Read Using Readonly vs. Immutability Libraries in TypeScript to pick a strategy.
Q9: How do I migrate an existing JavaScript Redux project to typed actions and reducers?
A9: Migrate slice-by-slice. Start by adding types to action creators, then the corresponding reducer. Use any in narrow scopes if necessary and gradually tighten the types. Our migration guide offers a step-by-step approach for JS-to-TS conversions; consider it when planning a large-scale migration.
Q10: Any tips on organizing types and files for large stores? A10: Keep slice-specific types next to slice implementation and export public types from an index in the slice folder. Centralize root types (RootState, RootAction) in a store module. See Organizing Your TypeScript Code: Files, Modules, and Namespaces for concrete layouts and module boundaries.
If you'd like, I can provide a small starter repository template with typed Redux slices, tsconfig settings tuned for stricter checks, and example React components wired with typed hooks. I can also show a migration checklist for converting an existing Redux app to this pattern.
