4 Comments

A New Redux Action Pattern for TypeScript 2.4+

In September 2016, I wrote a post about a pattern for strongly typed Redux reducers in TypeScript. Since then, the TypeScript team has been busy enhancing the language, and recent changes in TypeScript have enabled a much more natural, boilerplate-free pattern for strongly typed actions and reducers in TypeScript.

Our motivating example is to be able to write reducers like this:

Code example of a reducer that is fully typed by TypeScript.

This reducer is for a simple counter, which can increment or decrement a value in response to dispatched actions. We can dispatch(incrementCounter(5)) to increment the counter by five, or dispatch(decrementCounter(2)) to decrement by two, for example.

In particular, we want to maximize our leverage of TypeScript with minimal boilerplate. TypeScript should tell us if we use an action key that’s unsupported, and it should know that, when we switch on action.type, we’re narrowing the type of the action down to a specific possibility. Note that the action is inferred to be an IncrementAction in the screenshot above within the scope of the INC case.

Specifically, we want static type-checker support to:

  • Ensure that we switch on valid action types
  • Understand the properties of types in specific cases, allowing us to auto-complete property names such as by, as well as know those properties’ types
  • Warn us if we fail to add a default case, which will cause Redux to break when actions are dispatched by third-party libraries

Let’s look at how to do this in TypeScript 2.4 and up.

1. Define Action Type Keys

Our first step is to define an enumeration of all of the action type keys we wish to dispatch on in our application:

  export enum TypeKeys {
    INC = "INC",
    DEC = "DEC",
    OTHER_ACTION = "__any_other_action_type__"
  }

TypeScript 2.4 added support for string enums, which solve a number of problems with prior approaches. String enums give us a convenient way to define string constants that are narrowly typed to specific values – important for the rest of the technique. (An alternative is literal types – see my last post on the topic.)

(We’ll explain OTHER_ACTION shortly.)

2. Create Action Types

Next, we define types for our specific actions. Define each action type as follows:

  export interface IncrementAction {
    type: TypeKeys.INC;
    by: number;
  }

The important point here is the definition of type. Note that we’re defining type to have the type TypeKeys.INC. This tells TypeScript that all IncrementAction have exactly one value as their type, enabling TypeScript to prove that if we test action.type == TypeKeys.INC, we will have a by property that is a number.

How additional properties are organized is irrelevant – you can use flux standard actions with this technique as well.

3. Create a Type Representing All App Actions

Next, define a type representing all actions in your application by simply or-ing together all of your action types:

  export type ActionTypes = 
    | IncrementAction 
    | DecrementAction 
    | OtherAction;

We tend to do this at the top of our actions file. TypeScript types can refer to types defined later in the file without issue.

4. Create an “Other” Action Type

The list of actions we handle in our reducers is almost never the complete list of actions flowing through Redux. Third-party plugins and Redux built-in actions happen as well, and our reducers need to handle them appropriately. It’d be nice to get help from TypeScript so that we don’t forget.

Our preferred approach for this is to define an OtherAction type (which we never dispatch) that lives in our ActionTypes, so TypeScript will warn us if it’s not handled.

  export interface OtherAction {
    type: TypeKeys.OTHER_ACTION;
  }

We’ve stated here that OtherAction has a type with the value TypeKeys.OTHER_ACTION. This is a useful fiction. No action with type ”__any_other_action_type__" is ever dispatched.

Instead, we always make TypeScript happy in our reducers by including a default case. TypeScript thinks it’s there to handle OtherAction, but it’s really there for everything else.

Interlude: An Example Reducer

With this foundation, we can define reducers in terms of our actions, such as the following for a simple counter that increments/decrements a value:

function counterReducer(s: State, action: ActionsTypes) {
  switch (action.type) {
    case Actions.TypeKeys.INC:
      return { counter: s.counter + action.by };
    case Actions.TypeKeys.DEC:
      return { counter: s.counter - action.by };
    default:
      return s;
  }
}

TypeScript understands our types in a deep way. It’s able to prove that, within the INC case, our action is an IncrementAction and therefore has a by property. That property is not availability in our default branch, as it’s not common to other actions.

Additionally, our default case is enforced by TypeScript because of our OtherAction type. Without that, TypeScript would think our case statement exhaustive, when it is not.

5. Action Builders

Many action builders will simply be regular old TypeScript functions. No problem–just define the return type to be a specific action type (e.g. IncrementAction), and TypeScript will help you out.

However, many action creators just take an argument or two and put them in the corresponding place in the structure. It would be nice to reduce the boilerplate. To help us out with the simplest action creator cases, we’ve created a utility to generate simple, statically typed action creators.

Consider a simple creator for IncrementAction:

function incrementCounter(by: number) {
  return {
    type: TypeKeys.INC,
    by
  };
}

We can define this simply as:

const incrementCounter = 
    actionBuilder<IncrementAction>(TypeKeys.INC)("by");

Our actionBuilder takes an explicit type action type argument and the runtime type value for that action. The argument is statically determined to the exact type defined in the interface–any other argument is an error. It may seem redundant to pass the type argument in when the action has already defined the value, but it’s necessary. TypeScript types have no impact on runtime, so we must provide the runtime value. At least TypeScript will let us know if we screw it up.

actionBuilder is curried, and the function it returns takes zero or more string names of properties of the action type. These will be the arguments passed to the action builder function returned by the helper. In our case, the returned function will take a number, since the 'by' property of IncAction is a number.

In particular, the above two definitions of incrementCounter are equivalent in both type and behavior.

actionBuilder Definition

actionBuilder is defined as:

const actionBuilder = actionBuilderFactory<ActionsTypes>();

The interesting part is the definition of actionBuilderFactory, which follows:

/** Returns a function that builds action-builders for a family of action types, represented by the `ActionTypes` type argument */
function actionBuilderFactory<TActions extends { type: string }>() {
  return function<T extends { type: TActions["type"] }>(
    s: T["type"]
  ): ActionBuilder<T> {
    return (...keys: (keyof T)[]) => {
      return (...values: any[]) => {
        const action = <any>{ type: s };
        for (let i = 0, l = keys.length; i < l; i++) {
          action[keys[i]] = values[i];
        }
        return action as T;
      };
    };
  };
}


interface ActionBuilder<TAction> {
  (): () => TAction;
  <K1 extends keyof TAction>(k1: K1): (v1: TAction[K1]) => TAction;
  <K1 extends keyof TAction, K2 extends keyof TAction>(k1: K1, k2: K2): (
    v1: TAction[K1],
    v2: TAction[K2]
  ) => TAction;
  <
    K1 extends keyof TAction,
    K2 extends keyof TAction,
    K3 extends keyof TAction
  >(k1: K1, k2: K2, k3: K3): (v1: TAction[K1], v2: TAction[K2], v3: TAction[K3]) => TAction;
}

Note that our type definition supports only up to three arguments. If you’re under the impression that more than three arguments is a good idea, it’s easy to expand on by following the examples in ActionBuilder.

One downside of using this helper is that TypeScript won’t warn you if you don’t include every property of an action type–you could forget to pass 'by' into actionBuilder and generate invalid actions at runtime. This isn’t much of an issue in practice, and it’s worth the boilerplate reduction in our opinion. Complex builders are written by hand in any case, leveraging TypeScript to the full extent where useful.

The helper is unnecessary in any case, as you can always define all action builders by hand if you prefer.