Generic Redux Actions with TypeScript

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:

  1. action families are represented via a unique type based on the generic ThunkAction
  2. actions within a family share a common request: _Q and response: _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 ThunkActions 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.

Check it out on Github!

Featured