Redux Toolkit 20 ⚛️ TypeScript Thunks — Typing async thunks and payload creators
createAsyncThunk generates a thunk, a pending action, a fulfilled action, and a rejected action from a single call. In TypeScript, the types for those four artifacts are derived from three generic parameters: the return type of the payload creator, the type of the argument passed to the thunk, and an optional configuration object that defines thunkApi types like state, dispatch, and rejectValue. The first two can often be inferred from the payload creator’s signature. The third cannot, and that is where most of the typing work happens.
This chapter covers the generic parameters of createAsyncThunk, the AsyncThunkConfig object that defines the thunkApi types, the rejectWithValue pattern for typed error payloads, and the pre-typed createAppAsyncThunk helper that applies the store’s types once and uses them everywhere.
Key point: If the payload creator uses getState, dispatch, extra, or rejectWithValue, the third generic parameter is required. TypeScript cannot infer it, and once one explicit generic is supplied, the first two must also be supplied.
Why thunk typing has three layers
The inference problem. When the payload creator does not use thunkApi, the types are inferred from the function signature. The argument type comes from the first parameter, and the return type comes from the function’s return. The documentation shows this pattern: async (userId: number) => { ... } produces a thunk that accepts number and returns a promise of the payload creator’s return type .
The thunkApi problem. The second argument to the payload creator is thunkApi, which contains dispatch, getState, extra, and rejectWithValue. The types for these fields cannot be inferred from the function body, because they are not part of the function signature. They must be declared explicitly in the third generic parameter .
The all-or-nothing problem. TypeScript does not allow mixing explicit and inferred generic parameters. If the third parameter is supplied, the first two must also be supplied. This means a thunk that accesses getState must declare its return type and argument type explicitly, even if they would have been inferable .
a. The three generic parameters
The signature is createAsyncThunk<Returned, ThunkArg, ThunkApiConfig>(typePrefix, payloadCreator, options). The Returned type is the resolved value of the payload creator’s promise. The ThunkArg type is the argument passed when the thunk is dispatched. The ThunkApiConfig is an object type that defines the fields of thunkApi .
interface User {
id: string
name: string
}
interface FetchUserError {
message: string
code: number
}
const fetchUserById = createAsyncThunk<
User, // Returned: the payload creator's resolved value
string, // ThunkArg: the userId argument
{
state: RootState // getState() returns RootState
rejectValue: FetchUserError // rejectWithValue payload type
}
>('users/fetchById', async (userId, { getState, rejectWithValue }) => {
const state = getState()
try {
const response = await userAPI.fetchById(userId)
return response.data as User
} catch (err) {
return rejectWithValue({ message: err.message, code: 500 })
}
})
The state field types getState(). The rejectValue field types the argument to rejectWithValue and the payload of the rejected action. The dispatch field types thunkApi.dispatch, which is useful when the thunk dispatches other actions .
b. The rejectWithValue pattern
rejectWithValue is how a thunk returns a known error shape instead of letting the raw exception become the rejected payload. The type passed to rejectValue in the generic config becomes the type of action.payload in the rejected reducer and in the component that catches the thunk’s rejection .
// In the slice
builder.addCase(fetchUserById.rejected, (state, action) => {
// action.payload is FetchUserError | undefined
if (action.payload) {
state.error = action.payload.message
} else {
state.error = action.error.message ?? 'Unknown error'
}
})
The undefined in the union is important. If the payload creator throws an unexpected error instead of calling rejectWithValue, the rejected action’s payload is undefined. The reducer must handle both cases. The action.error field contains the serialized error from the thrown exception .
In a component, the thunk’s result can be unwrapped to get the typed error:
try {
const user = await dispatch(fetchUserById('1')).unwrap()
// user is User
} catch (err) {
// err is FetchUserError
}
The unwrap method throws the rejected payload, which allows try/catch to work with the typed error .
c. The pre-typed createAppAsyncThunk
Declaring the state and dispatch types on every thunk is repetitive. The withTypes helper, introduced in Redux Toolkit 1.9, creates a version of createAsyncThunk with the common types already applied. The helper is defined once in a utility file and imported wherever a thunk is created.
// app/withTypes.ts
import { createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
rejectValue: string
}>()
A thunk created with createAppAsyncThunk receives the typed getState and dispatch without any generic parameters. The rejectValue type is also pre-applied, so rejectWithValue('message') is type-checked against string .
// features/todos/todosSlice.ts
import { createAppAsyncThunk } from '../../app/withTypes'
export const fetchTodos = createAppAsyncThunk(
'todos/fetchTodos',
async (_, { getState }) => {
const state = getState() // typed as RootState
const response = await api.getTodos()
return response.data
}
)
The thunk’s return type and argument type are still inferred from the payload creator. Only the thunkApi types are pre-applied. This is the recommended pattern for applications with more than a handful of thunks .
Complete Example Session
// ============================================
// PART 1: INFERRED TYPES (NO thunkApi)
// ============================================
const fetchUser = createAsyncThunk(
'users/fetch',
async (userId: number) => {
const response = await fetch(`/api/users/${userId}`)
return (await response.json()) as User
}
)
// Returned: User
// ThunkArg: number
// No explicit generics needed.
// ============================================
// PART 2: EXPLICIT GENERICS WITH thunkApi
// ============================================
const fetchUserById = createAsyncThunk<
User,
string,
{ state: RootState; rejectValue: FetchUserError }
>('users/fetchById', async (userId, { getState, rejectWithValue }) => {
// ...
})
// ============================================
// PART 3: ACCESSING getState
// ============================================
const refreshToken = createAsyncThunk<
string,
void,
{ state: RootState }
>('auth/refresh', async (_, { getState, rejectWithValue }) => {
const token = getState().auth.token
if (!token) return rejectWithValue('No token')
return token
})
// getState() is typed as RootState.
// ============================================
// PART 4: TYPED rejectWithValue
// ============================================
const updateUser = createAsyncThunk<
User,
UserAttributes,
{ rejectValue: MyKnownError }
>('users/update', async (user, { rejectWithValue }) => {
const response = await fetch(`/api/users/${user.id}`, { method: 'PUT' })
if (response.status === 400) {
return rejectWithValue((await response.json()) as MyKnownError)
}
return (await response.json()) as User
})
// ============================================
// PART 5: HANDLING THE REJECTED ACTION
// ============================================
builder.addCase(updateUser.rejected, (state, action) => {
if (action.payload) {
state.error = action.payload.errorMessage
} else {
state.error = action.error.message ?? 'Unknown'
}
})
// action.payload is MyKnownError | undefined.
// ============================================
// PART 6: UNWRAPPING IN A COMPONENT
// ============================================
try {
const user = await dispatch(updateUser(attrs)).unwrap()
// user is User
} catch (err) {
// err is MyKnownError
}
// ============================================
// PART 7: THE PRE-TYPED HELPER
// ============================================
// app/withTypes.ts
import { createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
rejectValue: string
}>()
// ============================================
// PART 8: USING THE HELPER
// ============================================
// features/todos/todosSlice.ts
import { createAppAsyncThunk } from '../../app/withTypes'
export const fetchTodos = createAppAsyncThunk(
'todos/fetchTodos',
async (_, { getState, rejectWithValue }) => {
const state = getState() // RootState
// ...
}
)
// ============================================
// PART 9: DISPATCHING A TYPED THUNK
// ============================================
const dispatch = useAppDispatch()
dispatch(fetchUserById('1'))
// The thunk accepts string.
dispatch(fetchTodos())
// The thunk accepts no argument.
// ============================================
// PART 10: THE DECISION TREE
// ============================================
// Does the payload creator use thunkApi?
// No → infer types from the function signature
// Yes → supply all three generics
// or use createAppAsyncThunk
//
// Does the thunk return a known error shape?
// Yes → add rejectValue to the config
The ten parts covered inferred types, explicit generics, getState, rejectWithValue, the rejected reducer, unwrap, the pre-typed helper, using the helper, dispatching, and the decision tree.
Quick Reference
Generic parameters
| Parameter | Purpose | Inferred |
|---|---|---|
Returned | Payload creator’s resolved value | Yes (from return) |
ThunkArg | Argument passed to dispatch | Yes (from first param) |
ThunkApiConfig | thunkApi field types | No |
AsyncThunkConfig fields
| Field | Type | Purpose |
|---|---|---|
state | RootState | getState() return |
dispatch | AppDispatch | thunkApi.dispatch |
extra | Custom | thunkApi.extra |
rejectValue | Error type | rejectWithValue payload |
Pre-typed helper
| Item | Value |
|---|---|
| Helper | createAsyncThunk.withTypes<Config>() |
| Location | app/withTypes.ts |
| Usage | createAppAsyncThunk(typePrefix, payloadCreator) |
| Benefit | No generics on each thunk |
Error handling
| Method | Result |
|---|---|
rejectWithValue(err) | action.payload typed as rejectValue |
action.error | Serialized thrown error |
dispatch(thunk).unwrap() | Throws typed error on rejection |
Best Practices
✅ Do This:
// Use withTypes for a pre-typed helper
export const createAppAsyncThunk = createAsyncThunk.withTypes<{ state: RootState }>() // ✅
// Type the error shape with rejectValue
createAsyncThunk<User, string, { rejectValue: MyError }>(...) // ✅
// Handle both payload and error in the rejected reducer
if (action.payload) { ... } else { ... } // ✅
// Use unwrap in components for typed errors
await dispatch(thunk(arg)).unwrap() // ✅
❌ Don’t Do This:
// Supply only the third generic
createAsyncThunk<User, string, { state: RootState }>(...) // ❌ (must supply all three)
// Access getState without the state type
const state = getState() // ❌ (unknown)
// Assume action.payload is always defined in rejected
action.payload.message // ❌ (may be undefined)
// Declare state and dispatch on every thunk
// Use the pre-typed helper instead // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
getState() returns unknown | No state type in config | Add state: RootState |
| Generic error “expected 3 type arguments” | Only one or two supplied | Supply all three or use withTypes |
action.payload is undefined | Thunk threw instead of rejectWithValue | Check action.payload before accessing |
| Thunk dispatch type error | Plain Dispatch used | Use AppDispatch from the store |
unwrap() error is unknown | No rejectValue type | Add rejectValue to the config |
Real-World Examples
1. Inferred thunk
createAsyncThunk('users/fetch', async (id: number) => fetchUser(id))
2. Explicit generics
createAsyncThunk<User, string, { state: RootState }>('users/fetch', ...)
3. rejectValue
createAsyncThunk<User, string, { rejectValue: MyError }>('users/update', ...)
4. Typed getState
const state = getState() // RootState
5. Pre-typed helper
export const createAppAsyncThunk = createAsyncThunk.withTypes<{ state: RootState }>()
6. Using the helper
createAppAsyncThunk('todos/fetch', async (_, { getState }) => {})
7. Rejected reducer
builder.addCase(thunk.rejected, (state, action) => {
state.error = action.payload ?? action.error.message
})
8. unwrap in component
const result = await dispatch(thunk(arg)).unwrap()
9. extra type
createAsyncThunk<User, string, { extra: { jwt: string } }>('users/fetch', ...)
10. dispatch type
createAsyncThunk<User, string, { dispatch: AppDispatch }>('users/fetch', ...)
Visual
The three generic parameters
┌─────────────────────────────────────────────────────────────┐
│ createAsyncThunk<Returned, ThunkArg, ThunkApiConfig> │
│ │
│ Returned: │
│ The payload creator's resolved value │
│ Inferred from the return type │
│ │
│ ThunkArg: │
│ The argument passed to dispatch(thunk(arg)) │
│ Inferred from the first parameter │
│ │
│ ThunkApiConfig: │
│ { state, dispatch, extra, rejectValue } │
│ Cannot be inferred │
└─────────────────────────────────────────────────────────────┘
When generics are required
┌─────────────────────────────────────────────────────────────┐
│ PAYLOAD CREATOR USES thunkApi? │
│ │
│ No: │
│ async (id: number) => { ... } │
│ → types inferred │
│ │
│ Yes: │
│ async (id, { getState }) => { ... } │
│ → supply all three generics or use withTypes │
└─────────────────────────────────────────────────────────────┘
rejectWithValue flow
┌─────────────────────────────────────────────────────────────┐
│ REJECTWITHVALUE │
│ │
│ Payload creator calls rejectWithValue(error) │
│ │ │
│ ▼ │
│ Rejected action dispatched │
│ │ │
│ ▼ │
│ action.payload = error (typed as rejectValue) │
│ │ │
│ ▼ │
│ Reducer: if (action.payload) { ... } │
│ │ │
│ ▼ │
│ Component: dispatch(thunk).unwrap() │
│ → throws error │
└─────────────────────────────────────────────────────────────┘
The pre-typed helper
┌─────────────────────────────────────────────────────────────┐
│ WITH TYPES │
│ │
│ createAsyncThunk.withTypes<{ state: RootState }>() │
│ │ │
│ ▼ │
│ createAppAsyncThunk │
│ │ │
│ ▼ │
│ createAppAsyncThunk('todos/fetch', async (_, { getState }) => {})│
│ │ │
│ ▼ │
│ getState() is RootState without any generics │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Inferred generics | Returned and ThunkArg from payload creator |
Required for thunkApi | All three generics |
state field | Types getState() |
dispatch field | Types thunkApi.dispatch |
rejectValue field | Types rejectWithValue payload |
action.payload in rejected | rejectValue | undefined |
unwrap() on rejection | Throws the typed payload |
| Pre-typed helper | createAsyncThunk.withTypes<Config>() |
| Helper location | app/withTypes.ts |
Key takeaways:
- The first two generics are inferred; the third is not. The return type and argument type come from the payload creator’s signature. The
thunkApitypes must be declared explicitly, and once one generic is explicit, all three must be . rejectValuetypes the rejected action’s payload. The type passed torejectValuein the config becomes the type ofaction.payloadin the rejected reducer and the type thrown byunwrap(). The reducer must handleundefinedbecause a thrown error produces no payload .getState()isunknownwithout thestatetype. Thestatefield in the config is what typesgetState(). Without it, every property access on the state is a TypeScript error .- The pre-typed helper eliminates repetition.
createAsyncThunk.withTypes<{ state: RootState; dispatch: AppDispatch }>()creates a version of the function that applies those types to every thunk created with it. The helper is defined once and imported wherever a thunk is needed . - The helper still infers return and argument types. Only the
thunkApitypes are pre-applied. The thunk’s own signature is still derived from the payload creator, so the helper does not require generics at the call site . unwrap()is the component-side error handling pattern. It returns the fulfilled payload and throws the rejected payload. Thetry/catchreceives the typed error, which allows the component to display the error message without casting .
Remember: Typing a thunk is a decision about whether the payload creator uses thunkApi. If it does not, the types are inferred and no generics are needed. If it does, the state, dispatch, extra, and rejectValue types must be declared in the third generic, and the first two must be supplied alongside them. The pre-typed createAppAsyncThunk helper is the cleanest way to apply the store’s types once and use them everywhere. Use it for every thunk that accesses getState or rejectWithValue, and keep the explicit generic form for the rare thunk that needs a different thunkApi shape.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!