8 Comments

Typed Redux Reducers in TypeScript 2.0

Update: Using TypeScript 2.4 or newer? Then you need my post A New Redux Action Pattern for TypeScript 2.4+ instead.


TypeScript 2.0 was recently released. This version of TypeScript has some new additions that are great for Redux development in React: type narrowing for tagged unions.

Let’s look at an example from the Rangle.io Redux starter kit, a fantastic resource that’s been helpful for me as I’ve been diving into Redux development.

This reducer, which implements a simple increment/decrement counter, is written for TypeScript 1.8:


import {
  INCREMENT_COUNTER,
  DECREMENT_COUNTER,
  LOGOUT_USER
} from '../constants';
import { fromJS } from 'immutable';


const INITIAL_STATE = fromJS({
  count: 0,
});

function counterReducer(state = INITIAL_STATE, action = { type: '' }) {
  switch (action.type) {

  case INCREMENT_COUNTER:
    return state.update('count', (value) => value + 1);

  case DECREMENT_COUNTER:
    return state.update('count', (value) => value - 1);

  case LOGOUT_USER:
    return state.merge(INITIAL_STATE);

  default:
    return state;
  }
}

This example gets the job done, but it has a down side: We get zero help from TypeScript in writing our reducer. Both the counter model and our actions are pretty much dynamically typed in this example. There are existing solutions for statically typing Immutable.js models, so we’ll focus on dealing with the action in a more type-safe way.

Type Narrowing

To see the problem, consider if we wanted our action to carry a by property, allowing our counter to increment and decrement by different quantities. We’d then want to be able to refer to that in our handler:


switch (action.type) {
case INCREMENT_COUNTER:
    return state.update({value: state.value + action.by});
case DECREMENT_COUNTER:
    return state.update({value: state.value - action.by});
case LOGOUT_USER:
    return INITIAL_STATE;
default:
    return state;
}

TypeScript knows that our actions have a type property of type string, but our type allows for nothing else. And there’s no good way to add by to the type, as by isn’t global to all actions–it’s only relevant to increment and decrement. Previously, the only real option was to make action be of type any and get no validation whatsoever of our handling of by.

TypeScript 2.0 introduces the ability to narrow types based on a property with a fixed number of values. It’s specifically designed to handle this type of scenario. With TypeScript 2.0, we can update our definitions so that TypeScript will understand that our switch statement is “proving” the shape of action within each case, and allow us access to our additional properties. To do so requires a few simple additional steps.

1. Define constants

I glossed over the definition of the action type constants earlier. The examples in the starter kit I began with are simple constant definitions:


// TypeScript 1.8
export const INCREMENT_COUNTER = 'App/INCREMENT_COUNTER';
export const DECREMENT_COUNTER = 'App/DECREMENT_COUNTER';
export const LOGOUT_USER = 'App/LOGOUT_USER';
  

These simple constant declarations have type string–simply not specific enough to unlock TypeScript 2.0’s special handling. We need to update the constant definitions like so:


// Typescript 2.0
export type INCREMENT_COUNTER = 'App/INCREMENT_COUNTER';
export const INCREMENT_COUNTER : INCREMENT_COUNTER = 'App/INCREMENT_COUNTER';

export type DECREMENT_COUNTER = 'App/DECREMENT_COUNTER';
export const DECREMENT_COUNTER : DECREMENT_COUNTER = 'App/DECREMENT_COUNTER';

export type LOGOUT_USER = 'App/LOGOUT_USER';
export const LOGOUT_USER : LOGOUT_USER = 'App/LOGOUT_USER';
  

These new definitions take advantage of TypeScript’s ability to define literal types. For each constant, I’m defining a type that contains exactly one value–the string constant I’m looking for. I then create an instance of that type.

These values have more precise type information than the original. The original definitions had type string, and therefore their use didn’t really tell the compiler anything about the contents of the constant. The new definitions tell the compiler what specific value is allowed there. A function which takes a string can get any string, but a function which takes an INCREMENT_COUNTER can only get a value equal to 'App/INCREMENT_COUNTER'.

(String literal types are not new to TypeScript 2.0, but their use is key to enabling the new language features…though I do wish there were a better shorthand.)

2. Define action types

The next step is to describe to TypeScript the shapes of the different actions we wish to support.


export type IncrementCounterAction = {
    type: INCREMENT_COUNTER,
    by: number
};
export type DecrementCounterAction = {
    type: DECREMENT_COUNTER,
    by: number
};
export type LogoutUserAction = {
    type: LOGOUT_USER
};
    

Each of these types specifies what properties we expect to have with an action of a given type. Notice that we’re using our new INCREMENT_COUNTER etc. types here. IncrementCounterAction is an object with a type property that must equal 'App/INCREMENT_COUNTER' and a by property which is a number. It’s important to note that IncrementCounterAction is a subtype of all actions, which are { type: string }. This is because our INCREMENT_COUNTER type specifies a specific string, so it is therefore a subtype of all strings.

These granular action definitions are what we will use to inform TypeScript about parameter types in our reducers, but first we need to deal with a special case.

3. Define a type for “other actions”

Our reducer, like pretty much all Redux reducers, has a default case, to pass through actions which are irrelevant to this particular module. But doing so without inadvertently wrecking the delicate type constraints we’re so carefully building requires us to have a way to tell TypeScript:

1. There are additional values besides our explicit cases…
2. …without implying that those other cases could be type-incompatible with the actions we’ve declared.

I was able to do so by creating a special type to represent “other actions.”


export type OtherAction = { type: '' };
export const OtherAction : OtherAction = { type: '' };
    

Other actions have type as an empty string, which doesn’t intersect with any of our other types (something that ends up being an important property). We’ll see how this comes into play when we tie it all together:

Define a custom action type

After importing our new *Action types, we can now bring this all together by defining a type for counter actions and declaring that in counterReducer:


type CounterAction =
    IncrementCounterAction |
    DecrementCounterAction |
    LogoutUserAction |
    OtherAction

function counterReducer(state = INITIAL_STATE, action:CounterAction = OtherAction) {
    switch (action.type) {
    case INCREMENT_COUNTER:
        return state.update({value: state.value + action.by});

    case DECREMENT_COUNTER:
        return state.update({value: state.value - action.by});

    case LOGOUT_USER:
        return INITIAL_STATE;

    default:
        return state;
    }
}
    

This version properly type-checks in TypeScript 2.0 and, even better, it understands that action.by is a number in our reducer. We now get IDE support for accessing reducer properties for any case we wish to add in our CounterReducer. Let’s look at the components.

First, note that CounterAction unions together the types we’ve defined previously. This creates a single type for the cases we wish to handle in CounterReducer. Since each of our Action types has a string literal type for its type property, there are only a few options for the value of type on our action, so far as TypeScript is concerned–namely, the values of our action constants or an empty string.

Next, we’ve defined action to have type CounterAction and given it a default value of OtherAction. This is directly equivalent to the original example at the top of this post, with one change: OtherAction’s type is more specific.

Since TypeScript thinks it knows every possible value of type, and there is exactly one definition per action type, it proceeds to narrow the type of action within each case. Thus, within our INCREMENT_COUNTER branch, action is of type IncrementCounterAction. So all is good and TypeScript understands the world.

Subtle Lies

Not so fast! There’s one strange thing going on here. Our function is actually called with more action types than we’ve declared here. Every action in Redux gets channeled through our reducer, and yet our type seems to preclude all of these. What gives?

Since JavaScript is not a statically typed language, TypeScript understands that it may not always have the whole picture. Runtime values that violate the type system are possible, as type annotations are often incomplete decorations of existing JavaScript libraries.

We’re taking advantage of that property here in order for this technique to work.

Our OtherAction type has a more specific type definition than we actually rely on. We’re telling TypeScript that it must be an empty string, and then go on to handle the OtherAction case in a default case. This is crucial. Having the empty string in OtherAction is what tells TypeScript that we have one more case to handle after we’ve enumerated all others, but it thinks it knows what that value is. If we’d used type string for OtherAction‘s type, TypeScript wouldn’t be able to assume that an action in our INCREMENT_COUNTER branch wasn’t an OtherAction whose type happened to have the same value. (Kinda sneaky!)

This is also why it’s important to not make it easy to handle the OtherAction case with a case clause instead of a default. By handling it in our default case, we’re ensuring that handler can also take care of the real-world, non-blank values as well. Handling OtherAction in a case clause would break at runtime when e.g. system actions flow through the reducer. This slight lie about our types is what enables us to get the type narrowing behavior we crave in a Redux world that was designed to be open and dynamic from the start.

Extra Safety

And with that, we have a nice, type-safe way of defining Redux reducers, complete with inference on related properties and IDE support. It takes slightly more work than the untyped approach, but I’m looking forward to the extra safety this will bring me going forward.