Generic Redux Actions with TypeScript
- 3/1/2018
- ·
- #typescript
- #react
- #redux
- #howto
React, Redux, and TypeScript are a powerful combination, but gaining compile-time verification in a Redux application means adding more boilerplate on an already-weighty stack. I’ve written in the past about setting up and hacking down type-safe actions within Redux, but there’s always been room to improve.
Nowhere is that more true than for asynchronous actions, where a well-established flow from request to resolution leaves ample opportunity for repetition. Or, as it turns out, for eliminating it. Yes: have the cake! Eat it, too. We’re about to take full advantage of TypeScript’s type system with little more overhead than a pure-JavaScript Redux app.
Here’s where we’re headed:
export const saveCount = dispatcher(api.save)<SaveCount>(
asReq('SAVE_COUNT_REQUEST'),
asRes('SAVE_COUNT_SUCCESS'),
asErr('SAVE_COUNT_ERROR'))
See it? As an API request proceeds, saveCount
will dispatch appropriate redux
actions for the rest of the app to respond to. It’s type-safe, and virtually
painless, too!
But before we can get to the magic, though, our actions need a bit more definition.
In Action
We can model an asynchronous action in three stages. The first will represent the initial request (or initialization, or invocation, or whatever makes sense in your app), while the other two represent potential futures. In generic terms, an asynchronous action might look something like this:
type Q<T> = { request: T }
type S<T> = { response: T }
type E = { error: string }
export type Value = { value: number }
type ThunkAction<TQ, TS, TE, _Q, _S> =
({ type: TQ } & Q<_Q>)
| ({ type: TS } & Q<_Q> & S<_S>)
| ({ type: TE } & Q<_Q> & E)
Next, we could model the LOAD_
- and SAVE_COUNT
actions needed to sync a
simple counter in terms of ThunkAction
:
export type LoadCount = ThunkAction<
'LOAD_COUNT_REQUEST',
'LOAD_COUNT_SUCCESS',
'LOAD_COUNT_ERROR',
{},
Value
>
export type SaveCount = ThunkAction<
'SAVE_COUNT_REQUEST',
'SAVE_COUNT_SUCCESS',
'SAVE_COUNT_ERROR',
Value,
{}
>
This introduces several patterns that are worth noting, as we’ll need them in a moment:
- action families are represented via a unique
type
based on the genericThunkAction
- actions within a family share a common
request: _Q
andresponse: _S
First, though, let’s merge all six actions into a single Action
union
representing all the actions possible within the app:
export type Action =
LoadCount
| SaveCount
Keeping up a global Action
presents its own challenges, but it will simplify
type narrowing elsewhere in the app. And crucial to our
purposes, it provides the all-important type
field for further constraints.
Creating a dispatcher
Now that the Action
is defined, we’re on to the fun part: setting up a
strongly-typed dispatcher
that can map an asynchronous Thunk
on to the
ThunkAction
s we’ve just declared.
type Thunk<Q, S> = (request: Q) => Promise<S>
type Dispatch<A> = (a: A) => A
type ReqCreator<A, _Q> = (q: _Q) => A
type ResCreator<A, _Q, _S> = (q: _Q, s: _S) => A
type ErrCreator<A, _Q> = (q: _Q, e: string) => A
export const dispatcher = <_Q, _S>(fn: Thunk<_Q, _S>) =>
<A extends Action>(tq: ReqCreator<A, _Q>, ts: ResCreator<A, _Q, _S>, te: ErrCreator<A, _Q>) =>
(request: _Q) =>
(dispatch: Dispatch<A>) => {
dispatch(tq(request))
fn(request)
.then(response => dispatch(ts(request, response)))
.catch(err => dispatch(te(request, err.message)))
Beneath that swamp of generic parameters, dispatcher
translates a
promise-returning function into an action dispatcher fit for use for
redux-thunk. The actions it produces will map to one
of the ThunkActionGroup
permutations we’ve defined previously.
We’ll also need a way to tag type
parameters as suitable for use with each of
the action-Creator
methods:
// Shorthand reference to the `type` field
type _T = Action['type']
export const asReq = <TQ extends _T>(type: TQ) =>
<_Q>(request: _Q) =>
({ type, request })
export const asRes = <TS extends _T>(type: TS) =>
<_Q, _S>(request: _Q, response: _S) =>
({ type, request, response })
export const asErr = <TE extends _T>(type: TE) =>
<_Q>(request: _Q, error: string) =>
({ type, request, error })
Action mappings
With the dispatcher
and tagging methods in hand, we have all the tools we need
to map a Thunk
over its corresponding ThunkAction
.
import { api } from '../api'; // e.g.
export const loadCount = dispatcher(api.load)<LoadCount>(
asReq('LOAD_COUNT_REQUEST'),
asRes('LOAD_COUNT_SUCCESS'),
asErr('LOAD_COUNT_ERROR'))
export const saveCount = dispatcher(api.save)<SaveCount>(
asReq('SAVE_COUNT_REQUEST'),
asRes('SAVE_COUNT_SUCCESS'),
asErr('SAVE_COUNT_ERROR'))
Now the rest of the app can now count on well-formed actions as the thunk proceeds.
saveCount({ value: 42 })(dispatch)
That’s it, all of it. A few type declarations plus a bit of generic boilerplate later, and your Redux application is ready to dispatch asynchronous functions as reducer-ready actions.