Friday, 02 August 2019
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 that eliminates a lot of this boilerplate and friction.
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; } }
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; } }
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.
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:
Type information for action parameters are repeated:
Maintenance of the following feels very manual:
What I'd like is:
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); }
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:
const handlers = { increment(state: State, data: Json<{ by: Date }>): State { state.value += data.by; return state; }, // ... };
You'd get an error when you attempt to access any properties on the object.
Property 'by' does not exist on type 'InvalidJson<{ by: Date; }>'.
2019-08-03: Replaced the Handlers class with a plain object based on feedback from lobste.rs
2019-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.