02 Aug 2019

Redux and TypeScript - Improving on recommended patterns

I've been using both Redux and TypeScript for a while and, honestly, they don't play well together. Adding types for actions and state and making sure you've handled all the cases in your reducer leads to a lot of boilerplate. Here is a new approach I've been trying for TamaWiki which eliminates a lot of this boilerplate and friction.

The traditional approach

I'll start by describing a common pattern first. This is roughly the recipe described on the Redux website, and perhaps the most widespread approach to adding type information to a Redux application.

// Action keys

enum TypeKeys {
    INCREMENT = "INCREMENT",
    DECREMENT = "DECREMENT",
    SET_AMOUNT = "SET_AMOUNT",
}

// Action types

interface IncrementAction {
    type: TypeKeys.INCREMENT,
    by: number
}

interface DecrementAction {
    type: TypeKeys.DECREMENT,
    by: number
}

interface SetAmountAction {
    type: TypeKeys.SET_AMOUNT,
    to: number
}

type Action =
    | IncrementAction
    | DecrementAction
    | SetAmountAction;

// State type

interface State {
    value: number
}

const initial: State = {
    value: 0
};

// Typed reducer

function reducer(state: State = initial, action: Action): State {
    switch (action.type) {
        case TypeKeys.INCREMENT:
            state.value += action.by;
            return state;
        case TypeKeys.DECREMENT:
            state.value -= action.by:
            return state;
        case TypeKeys.SET_AMOUNT:
            state.value = action.to;
            return state;
        default:
            return state;
    }
}

As your application grows it's quite possible you'll end up with a hundred or more actions. All of these pieces need constant maintenance and adding types for actions and their keys quickly becomes onerous.

As is common with all Redux applications, managing scopes in the big switch statement quickly becomes unwieldy too. I often end up breaking the code into separate handler functions, which helps, but also adds more boilerplate!

function handleIncrement(state: State, by: number): State {
    state.value += by;
    return state;
}

function handleDecrement(state: State, by: number): State {
    state.value -= by;
    return state;
}

function handleSetAmount(state: State, to: number): State {
    state.value = to;
    return state;
}

function reducer(state: State = initial, action: Action): State {
      switch (action.type) {
          case TypeKeys.INCREMENT: return handleIncrement(state, action.by);
          case TypeKeys.DECREMENT: return handleDecrement(state, action.by);
          case TypeKeys.SET_AMOUNT: return handleSetAmount(state, action.to);
          default:
              return state;
      }
  }

Eliminating separate action keys

One quick win is to simply remove the separate TypeKeys enum. Our TypeScript enum is taking the place of the more traditional action type constants in vanilla Redux.

// Action type constants as recommended by Redux

const INCREMENT = "INCREMENT";
const DECREMENT = "DECREMENT";
const SET_AMOUNT = "SET_AMOUNT";

One of the main reasons Redux recommends this is to avoid making typos when creating actions. By importing a constant, you'll get some early warning if you mess up the name.

I think many people emulate this in TypeScript without much thought, but TypeScript will check this for you. By replacing the TypeKeys value with a string literal, TypeScript will still ensure Actions use the correct string at compile time.

// Action types

interface IncrementAction {
    type: "INCREMENT",
    by: number
}

interface DecrementAction {
    type: "DECREMENT",
    by: number
}

interface SetAmountAction {
    type: "SET_AMOUNT",
    to: number
}

type Action =
    | IncrementAction
    | DecrementAction
    | SetAmountAction;

// ...

function reducer(state: State = initial, action: Action): State {
    switch (action.type) {
        // These get type checked too!
        case "INCREMENT": return handleIncrement(state, action.by);
        case "DECREMENT": return handleDecrement(state, action.by);
        case "SET_AMOUNT": return handleSetAmount(state, action.to);
        default:
            return state;
    }
}

Bonus: detecting unhandled actions

This tip doesn't reduce boilerplate but does address one of my pet peeves with the Redux switch statement. How do you know each action has code in the reducer to handle it?

function reducer(state: State = initial, action: Action): State {
    switch (action.type) {
        case "INCREMENT": return handleIncrement(state, action.by);
        case "DECREMENT": return handleDecrement(state, action.by);
        default:
            return state;
    }
}

In the above code "SET_AMOUNT" is not handled. The only way to find that out currently is at runtime. Hopefully in our unit tests.

By using the never type, we can check all actions have a handler at compile time.

function assertNever(state: State, _: never): State {
    return state;
}

function reducer(state: State = initial, action: Action): State {
    switch (action.type) {
        case "INCREMENT": return handleIncrement(state, action.by);
        case "DECREMENT": return handleDecrement(state, action.by);
        case "SET_AMOUNT": return handleSetAmount(state, action.to);
        default:
            // Check all action types have been handled at compile time,
            // but return current state if called at runtime.
            return assertNever(state, action);
    }
}

The one complication is that Redux has internal actions like @@INIT you're not meant to handle. So at runtime we're likely to accidentally execute assertNever() as the default handler.

To handle this, we only perform the action type check at compile time (by comparing action to the type never). At runtime, assertNever() will safely return the current state.

Aggressive cleanup: generating action types from handlers

OK, let's review where we are. Here's the code so far.

// Action types

interface IncrementAction {
    type: "INCREMENT",
    by: number
}

interface DecrementAction {
    type: "DECREMENT",
    by: number
}

interface SetAmountAction {
    type: "SET_AMOUNT",
    to: number
}

type Action =
    | IncrementAction
    | DecrementAction
    | SetAmountAction;

// State type

interface State {
    value: number
}

const initial: State = {
    value: 0
};

// Typed reducer

function handleIncrement(state: State, by: number): State {
    state.value += by;
    return state;
}

function handleDecrement(state: State, by: number): State {
    state.value -= by;
    return state;
}

function handleSetAmount(state: State, to: number): State {
    state.value = to;
    return state;
}

function assertNever(state: State, _: never): State {
    return state;
}

function reducer(state: State = initial, action: Action): State {
    switch (action.type) {
        case "INCREMENT": return handleIncrement(state, action.by);
        case "DECREMENT": return handleDecrement(state, action.by);
        case "SET_AMOUNT": return handleSetAmount(state, action.to);
        default:
            // Check all action types have been handled at compile time,
            // but return current state if called at runtime.
            return assertNever(state, action);
    }
}

Wow. OK. That's a lot of code to add and subtract some numbers. Here are a few things that stand out to me:

  • Action names exist in three places:
    • The action type itself
    • The handler that's named after it
    • The cases of the switch statement
  • Type information for action parameters are repeated:
    • In the action's type
    • In the action's handler function
  • Maintaining the union type for Action feels very manual.
  • Maintaining the dispatch to handlers in the switch statement feels very manual.

What I'd like is:

  • To no longer manually maintain a union type for all actions.
  • One place to define action parameter types and names.
  • A dispatcher to replace the switch statement so I don't have to touch it any more.
  • To make sure I've handled all the action types.

I think the logical place to collect a lot of this information is in the handlers themselves. I'm going to start by putting the handlers into an object because that gives us the opportunity to map over them in TypeScript.

const handlers = {
    increment(state: State, by: number): State {
        state.value += by;
        return state;
    },
    decrement(state: State, by: number): State {
        state.value -= by;
        return state;
    },
    setAmount(state: State, to: number): State {
        state.value = to;
        return state;
    }
};

We can then map over the handlers object to extract some type names.

type Actions = {[T in keyof typeof handlers]: {type: T}};

The above mapped type is equivalent to:

type Actions = {
    'increment': {type: 'increment'},
    'decrement': {type: 'decrement'},
    'setAmount': {type: 'setAmount'},
};

It's a break from the convention of ALL_CAPS action names in Redux, but otherwise works fine. The actions don't have any parameters yet, but we'll come to that.

To create a type for a specific action we can index into this Actions type.

type Action = Actions[keyof Actions];

Which is equivalent to:

type Action =
    | {type: 'increment'}
    | {type: 'decrement'}
    | {type: 'setAmount'};

So, how to add those parameters? Turns out TypeScript has a Parameters type that might be useful here.

We'll start by collecting all the parameters into a generic data parameter (all our handlers currently have the same number of parameters but that may not be true in future).

const handlers = {
    increment(state: State, data: {by: number}): State {
        state.value += data.by;
        return state;
    },
    decrement(state: State, data: {by: number}): State {
        state.value -= data.by;
        return state;
    },
    setAmount(state: State, data: {to: number}): State {
        state.value = data.to;
        return state;
    }
};

We can then access the data parameter in our mapped type.

type Actions = {
    [T in keyof typeof handlers]:
    { type: T, data: Parameters<typeof handlers[T]>[1] }
};

Which is equivalent to:

type Actions = {
    'increment': {type: 'increment', data: {by: number}},
    'decrement': {type: 'decrement', data: {by: number}},
    'setAmount': {type: 'setAmount', data: {to: number}},
};

Making Action equivalent to:

type Action =
    | {type: 'increment', data: {by: number}}
    | {type: 'decrement', data: {by: number}}
    | {type: 'setAmount', data: {to: number}};

That avoids repeating action names and parameters in two places because we're generating the action type definitions from the handler functions!

Now, let's see if we can get rid of that switch statement.

function reducer(state: State = initial, action: Action): State {
    if (handlers.hasOwnProperty(action.type)) {
        return handlers[action.type](
            state,
            (action as any).data
        );
    }
    // Internal redux action
    return state;
}

Since we know every Action has a matching handler (because it's generated from it), we can simply ignore the type information here and safely dispatch the action to it's associated handler.

We can remove the assertNever(state, action) safety check too because by definition every Action is handled.

How are things looking now?

// --- Here are the things you update ---

interface State {
    value: number
}

const initial: State = {
    value: 0
};

const handlers = {
    increment(state: State, data: { by: number }): State {
        state.value += data.by;
        return state;
    },
    decrement(state: State, data: { by: number }): State {
        state.value -= data.by;
        return state;
    },
    setAmount(state: State, data: { to: number }): State {
        state.value = data.to;
        return state;
    }
};

// --- You should never need to touch these again ---

type Actions = {
    [T in keyof typeof handlers]:
    { type: T, data: Parameters<typeof handlers[T]>[1] }
};

// Action type generated from Handler method names and data parameter
type Action = Actions[keyof Actions];

function reducer(state: State = initial, action: Action): State {
    if (handlers.hasOwnProperty(action.type)) {
        return handlers[action.type](
            state,
            (action as any).data
        );
    }
    // Internal redux action
    return state;
}

Looking at the parts you actually need to update, this looks a lot more manageable!

// This is what your actions look like

dispatch({
    type: 'increment',
    data: {
        by: 123
    }
})

Of course, you'll want to make sure dispatch() checks its parameter against your new Action type. One simple way would be to just wrap Redux's dispatch with your own dispatch function.

function dispatch(action: Action) {
    store.dispatch(action);
}

Bonus: check your store is JSON safe

Redux recommends you only put JSON serializable plain objects into your store to ensure things like time-travel debugging continue to work. If that's something you care about, we can also check this with TypeScript!

Here's a JSON type definition I've been using:

interface JsonObject { [name: string]: JsonValue }
interface JsonArray extends Array<JsonValue> { }
type JsonValue = (null | boolean | number | string | JsonObject | JsonArray);

type Json<T> = T extends JsonValue ? T : InvalidJson<T>;
// The InvalidJson type only exists to present nicer error messages
// than using the never type.
interface InvalidJson<_> { };

Wrapping any type with Json<...> will check at compile-time that you're only using JSON serializable data inside the type.

For example, we could use it in our Handler definitions to make sure action parameters are JSON safe.

const handlers = {
    increment(state: State, data: Json<{ by: number }>): State {
        state.value += data.by;
        return state;
    },
    // ...
};

If for some reason you used a property that wasn't valid JSON, like Date, you'd get an error when you attempt to access any properties on the object.

const handlers = {
    increment(state: State, data: Json<{ by: Date }>): State {
        state.value += data.by;
        return state;
    },
    // ...
};
Property 'by' does not exist on type 'InvalidJson<{ by: Date; }>'.

TL;DR

  • Split reducer into separate handler functions.
  • Generate action type definitions from those handler functions.
  • Optionally check for JSON compatibility in your store.

Any questions or feedback? Send me a comment by email and I'll try to include any useful information in this page.

Edit: (2018-08-03) Replaced the Handlers class with a plain object based on feedback from lobste.rs.

Edit: (2018-08-16) Removed the ReduxAction type and associated type guard after my friend Glen Mailer pointed out having assertNever() return the current state at runtime might be simpler and safer.