23 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/decrement 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

Action builders are also simple – just regular functions. We do recommend explicitly typing the return value so that TypeScript will check the action builder for compliance with the type:

const incrementCounter = (by: number): IncrementAction => ({
  type: TypeKeys.INC,
  by
});

And that’s it! Fully typed actions and reducers for Redux with minimal boilerplate.