Warhol

Reducing Redux boilerplate in TypeScript

Published on 24.10.2019 by Peter Kröner

The web development community loves to argue about boilerplate in Redux applications, but in my opinion the most common complaints are entirely unjustified. Redux is little more than a state machine and the aspects of Redux that people usually get angry about (like actions and action creators) are essential to state machines. You can't build a state machine in any library or programming language without actions and some way to create them. A state machine may be a poor fit for your application or your way of programming, but this is not the state machine's fault. When people complain about "Redux boilerplate" their underlying problem is usually that they picked the wrong tool for the job.

Redux by itself is perfectly fine and (as far as state machines go) not particularly verbose or boilerplate-heavy… at least unless you add TypeScript to the mix.

Consider the following example:

type IncrementAction = Readonly<{
  readonly type: "INCREMENT",
  payload: number,
}>;

export const increment = (amount: number): IncrementAction => {
  return {
    type: "INCREMENT",
    payload: amount,
  };
};

type DecrementAction = Readonly<{
  readonly type: "DECREMENT",
  payload: number,
}>;

export const decrement = (amount: number): DecrementAction => {
  return {
    type: "DECREMENT",
    payload: amount,
  };
};

export type AnyAction = IncrementAction | DecrementAction;

This is a lot, but still looks like somewhat reasonable code that you may find in any project that uses Redux and TypeScript. There are type declarations for the actions (type declarations being one of the main points of using TypeScript), the action creators themselves (essential to Redux) and the discriminated union type AnyAction over all possible actions. The union type makes it easy to distinguish between the different kinds of actions in reducers by switching on the action's type property.

I would still not call this code boilerplate-heavy, as all the moving pieces are required to make the actions work in the context of Redux and TypeScript… but it starts to look somewhat verbose. The actions returned from the action creators are very simple objects and the code written to declare their types is amost identical to the object literals for the actual runtime objects. We are basically writing the same object literal twice. Even worse, whenever an action changes, we have to apply virtually identical updates to the type and the object literal. At the same time, the individual action types are not really useful by themselves - they are just building blocks for the action creator's types and the union AnyAction.

In Warhol's various moving pieces like the web app and the browser extension, we rely heavily on React, Redux and TypeScript and used to get frustrated by the issues outlined above. We now employ a variety of techniques to reduce redundant code when dealing with actions and action creators and this post describes the basics behind one of our approaches.

Action types from (inferred) action creator types

The obvious way to prevent us from having to write the same object literal twice every time we build an action creator is TypeScript's built-in utility type ReturnType<T>:

export const increment = (amount: number) => {
  return {
    type: "INCREMENT",
    payload: amount,
  } as const;
};

type IncrementAction = ReturnType<typeof increment>;

export const decrement = (amount: number) => {
  return {
    type: "DECREMENT",
    payload: amount,
  } as const;
};

type DecrementAction = ReturnType<typeof decrement>;

export type AnyAction = IncrementAction | DecrementAction;

ReturnType<T> is a type that takes a function's type and extracts the return type from it. This works without there being an explicit return type annotation anywhere on the function type or even a declaration for the function type itself - we can extract this by using TypeScript's typeof operator on the runtime function object. The const assertion serves as a replacement for Readonly<T> - it prevents the type of the type field from being widened to string and in doing so ensures that AnyAction still works as a discriminated union.

Constructing the object types from the functions that create them does not compromise type safety in any way, because every function without explicit type annotations has a return type in TypeScript thanks to type inference.

Using this approach, we can skip writing redundant object literals/types and extract the building blocks for the union type from the action creators. This looks like an improvement, but in reality we traded writing redundant object types for writing redundant ReturnType<typeof something> declarations. This is true boilerplate code as defined by Wikipedia, code "…that [has] to be included in many places with little or no alteration". We have to do better than this!

At this point the individual action types are only needed to build the union over all possible actions. Instead of declaring the action types we can just use ReturnType<T> to construct the union from the action creator's return types:

export const increment = (amount: number) => {
  return {
    type: "INCREMENT",
    payload: amount,
  } as const;
};

export const decrement = (amount: number) => {
  return {
    type: "DECREMENT",
    payload: amount,
  } as const;
};

export type AnyAction = ReturnType<typeof increment> | ReturnType<typeof decrement>;

We now have gotten rid of individual action types, but still must remember to extend the declaration for AnyAction each time we add a new action creator. In actuality, we want AnyAction to mean "any object returned by any action creator in this module". This is possible, but probably requires a bit of explaining.

Action types inferred from module types

The code to automatically create a discriminated union over all possible action types from action creator functions looks as follows:

type ActionCreators = typeof import("./actionCreators");

export type AnyAction = {
  [Name in keyof ActionCreators]: ActionCreators[Name] extends ((...args: any[]) => any)
    ? ReturnType<ActionCreators[Name]>
    : never
}[keyof ActionCreators];

The type ActionCreators is constructed from a dynamic import statement and is an object type that maps exported objects names to the type of the exported objects. In this case, as we import a library of action creators, it maps function names to function types (roughly equivalent to Redux' own type ActionCreatorsMapObject). From this, a mapped object type transforms the ActionCreators into a type that maps the action creators function names to the action creator's return types, meaning the actual actions' types. Just in case the module exports something that is not an action creator function, a conditional type makes sure that all non-functions map to never. The mapped type returns an object type (mapping function names to return types), which we can simply turn into a union by indexing the type by its own keys.

The end result is a single source of truth for actions in the form of (implicit, but nevertheless static) return types from action creators. Given the following two action creators in actionCreators.ts

export const increment = (amount: number) => {
  return {
    type: "INCREMENT",
    payload: amount,
  } as const;
};

export const decrement = (amount: number) => {
  return {
    type: "DECREMENT",
    payload: amount,
  } as const;
};

… the type AnyAction ends up being equivalent to writing:

type AnyAction = {
  readonly type: "INCREMENT";
  readonly payload: number;
} | {
  readonly type: "DECREMENT";
  readonly payload: number;
}

We gain all the benefits of static typing (primarily a discriminated union for filtering actions in reducers) without having to write any TypeScript-specific boilerplate code for every new action creator we add. A single, short type transformation lets us focus on writing the actual code to make our state machine work - it feels almost like writing pure JavaScript.

Caveats

For some reason most apps written with Redux spread action creators across many files. This makes creating AnyAction a bit more complicated, as we then have to import all action creator modules and merge their types. This is by no means rocket science…

// Action creator modules
type FooActionCreators = typeof import("./foo/actionCreators");
type BarActionCreators = typeof import("./bar/actionCreators");

// Merge object types with an intersection type
type ActionCreators = FooActionCreators & BarActionCreators;

// Works just like before
export type AnyAction = {
  [Name in keyof ActionCreators]: ActionCreators[Name] extends ((...args: any[]) => any)
    ? ReturnType<ActionCreators[Name]>
    : never
}[keyof ActionCreators];

… but introduces a new source of problems. Each time we add an action creator module we have to remember to import it and to add its type to the intersection type ActionCreators - and having to remember things never works reliably. I'm a fan of having just having a single, long file that defines all action creators. Actions rarely map to specific concepts in a neat and tidy way and thus many actions cause changes in more than one reducer. So why try to fit a peg with a mostly undefined shape into a round hole? But if you want to try, just remember to update your intersection type.

Usage in Warhol

This exact technique is not in use (anymore) in any of Warhol's apps, but variations of it are. The truth is that much of Warhol operates under even more constraints than a typical single page application. In turn, we have to apply even more black type magic to keep things nice and DRY. The browser extension (as the one for Chrome) is essentially a set of SPAs that have to communicate across browsing contexts using postMessage() while still being statically typed without us having to manually cast types or write endless annotations. But this is a topic for another time - subscribe to our feed or the newsletter to keep up to date with Warhol and our posts on dev-related shenanigans.

A picture of the author

Written by

Peter Kröner

Trainer for frontend technologies, podcaster @workingdraft.

Twitter

More from the blog

Be the first to know

You want to know more? Stay tuned and subscribe to our mailing list. We keep you posted of the progress of Warhol.