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.
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.
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!
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.
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.
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?
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.
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.
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
Maintenance of the following feels very manual:
The union type for Action
The dispatch to handlers in the switch statement
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.
We can then map over the handlers object to extract some type names.
The above mapped type is equivalent to:
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.
Which is equivalent to:
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).
We can then access the data parameter in our mapped type.
Which is equivalent to:
Making Action equivalent to:
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.
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?
Looking at the parts you actually need to update, this looks a lot more manageable!
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.
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:
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.
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.
Split reducer into separate handler functions.
Generate action type definitions from those handler functions.
Optionally check for JSON compatibility in your store.
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.