Some tips on type safety with Redux

Dhruv Rajvanshi

I’ve been using Redux/Typescript in a medium sized project and I’m one of those people who don’t trust themselves at all at writing code. My first attempts at writing type safe redux code were usable but had a lot of boilerplate type annotations. After some research and with some help from Anders Hejlsberg himself, I’ve come up with a few patterns that may help you in creating type safe action creators and reducers.

Obviously if you clicked on this article, you care about type safety. If you’re one of those 10xers who scoffs at type safety by saying “Type safety is for careless programmers” (an actual thing someone said to me), I would reply by saying “Seat belts are for careless drivers”.

This is a very opinionated way and there might be other ways of dealing with it but I’ll just describe mine.

The type of an action

An action is just an object with a type (usually a string) and a payload (which may have a different type for each type of action in your application).

We start by defining a simple type which maps from action types to the type of the payload for that action.

type IActionMap = {
"ACTION_1": IPayload1;
"ACTION_2": IPayload2;
"ACTION_3": IPayload3;
...
}

IPayloadN can be any Typescript type you want.

Now, we want a type which basically is a union of all the actions described by this map.

Let’s start with a type that is a union of all our action types (the string tags not the object).

type IActionType = keyof IActionMap;

This is just a concise way of writing

type IActionType = "ACTION_1" | "ACTION_2" | ... | "ACTION_N"

Next, we need a generic type that takes an IActionType and gives us the corresponding payload type.

type IActionPayload<K extends IActionType> = IActionMap[K];

So, IActionPayload<"ACTION_1"> will be equivalent to IPayload1 and so on. I won’t go into details on how IActionMap[K] thing works but you can essentially pass the type of a key of an object type using [] to get the type of the key’s corresponding value type in that object type. Ignore this if you don’t understand because this type level trickery is only needed once in your application. Also note that the generic argument to IActionPayload (K) has a bound on it extends IActionType which means you can’t just pass in any type to IActionPayload. Only the types of your actions.

Next, we move on to getting the type of an action object from a given key.

import { Action } from "redux";
// We're inheriting from Action defined in the type definition
// of redux so we can pass our actions to dispatch
interface IActionObject<K extends IActionType> extends Action {
type: K;
payload: IActionPayload<K>;
}

This is just saying that given an IActionType K, IActionObject<K> will be the type of the object with that key and the corresponding payload type from our action map. So, IActionObject<"ACTION_1"> will be equivalent to

{ type: "ACTION_1", payload: IPayload1 }

Next, we can get finally the union type of all our actions by using the same type level trickery we did earlier.

type IAction = {
[P in IActionType]: IActionObject<P>
}[IActionType];

Again, the last part may be a bit confusing because we’re passing a key type to an object type. I won’t even attempt to explain this one.

So, all of it put together,

import { Action } from "redux";type IActionType = keyof IActionMap;
type IActionPayload<K extends IActionType> = IActionMap[K];
interface IActionObject<K extends IActionType> extends Action {
type: K;
payload: IActionPayload<K>;
}
type IAction = {
[P in IActionType]: IActionObject<P>
}[IActionType];

I know it looks a bit awful but the payoff is worth it.

Now, for convenience, we can make a function that makes action creators from action types.

function actionCreator<Type extends IActionType>(type: Type) {
return (payload: IActionPayload<Type>): IActionObject<Type> =>
({type: type, payload: payload});
}

This is a higher order function that returns an action creator from a given action type.

So, if you do,

const action1 = actionCreator("ACTION_1");

action1 is now a function that takes 1 argument of type IPayload1 and returns the action. If you try to pass a value of a type other than IPayload1 to it, the compiler will complain. Nice!

Now, your reducers can have type

(state: YourState, action: IAction) => YourState

Thanks to Typescript’s discriminated unions, if you do

if (action.type === "ACTION_1") { ... }

action.payload will be inferred to have the type IPayload1 in the if block. There’s really no way for you to mess up the types in your reducers now.

You can be happy with that but wait there’s more.

Getting rid of those horrible switch/cases

Take a look at this

export type ReducerCases<State> = {
[T in IActionType]: (
state: State,
payload: IActionPayload<T>
) => State;
};
function createReducer<State>(
cases: Partial<ReducerCases<State>>
) {
return function (state: State, action: IRootAction): State {
const fn = cases[action.type];
if (fn) { // the "as any" part is a bit of a shame but ignore it
return (fn as any)(state, action.payload, action);
} else {
return state;
}
};
}

I won’t go into the details of the types here, but it essentially allows you do do this:

createReducer<YourState>({
"ACTION_1": (state, payload) => { ...; return newState },
"ACTION_2": (state, payload) => ...,
});

You obviously don’t have to handle all cases in a single reducer. If the action type is not handled, the argument state is returned. Also, payload in each “branch” of the cases is inferred correctly.

You probably should factor out the case handlers to different functions instead of writing them as lambdas for hygiene.

You might have noticed that the createReducer function is also mentioned in the redux docs. The only difference here is that this is type safe.

Also, note that you have to write out the names of the actions in the case object as a string literal. You can’t use computed properties like this

const ACTION_1 = "ACTION_1";
createReducer({
[ACTION_1]: ...
})

This is because of a limitation in Typescript that computed properties are always treated as string types. Even if you wrote a type annotation in your const declaration like const ACTION_1: “ACTION_1" = “ACTION_1", you wouldn’t get type safety.

Bonus

You can also split your action maps across files, as I did.

type IActionMap1 = { ... }
type IActionMap2 = { ... }
type IActionMap = IActionMap1 & IActionMap2

This is useful if your want to split your actions by domain/module.

Conclusion

With some simple (well…maybe not that simple but definitely write once) type definitions, you’ve prevented a lot of ways of shooting yourself in the foot. If you change the action types, you get a compiler error, changing payload type, same, matching on a wrong action type, ditto. Creating actions with wrong types, you bet. The only way you can mess up is by assigning a wrong type to an action in the action map. This will probably be caught when you try to write a reducer case for it though.

Anyway, these things have helped me a lot in my codebase.

Feedback and suggestions welcome. Cheers.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade