In September 2016, I wrote a post about a pattern for strongly typed Redux reducers in TypeScript. Since then, the TypeScript team has been busy enhancing the language, and recent changes in TypeScript have enabled a much more natural, boilerplate-free pattern for strongly typed actions and reducers in TypeScript.
Our motivating example is to be able to write reducers like this:
This reducer is for a simple counter, which can increment or decrement a value in response to dispatched actions. We can dispatch(incrementCounter(5))
to increment the counter by five, or dispatch(decrementCounter(2))
to decrement by two, for example.
In particular, we want to maximize our leverage of TypeScript with minimal boilerplate. TypeScript should tell us if we use an action key that’s unsupported, and it should know that, when we switch on action.type
, we’re narrowing the type of the action down to a specific possibility. Note that the action is inferred to be an IncrementAction
in the screenshot above within the scope of the INC
case.
Specifically, we want static type-checker support to:
- Ensure that we switch on valid action types
- Understand the properties of types in specific cases, allowing us to auto-complete property names such as
by
, as well as know those properties’ types - Warn us if we fail to add a
default
case, which will cause Redux to break when actions are dispatched by third-party libraries
Let’s look at how to do this in TypeScript 2.4 and up.
1. Define Action Type Keys
Our first step is to define an enumeration of all of the action type keys we wish to dispatch on in our application:
export enum TypeKeys {
INC = "INC",
DEC = "DEC",
OTHER_ACTION = "__any_other_action_type__"
}
TypeScript 2.4 added support for string enums, which solve a number of problems with prior approaches. String enums give us a convenient way to define string constants that are narrowly typed to specific values – important for the rest of the technique. (An alternative is literal types – see my last post on the topic.)
(We’ll explain OTHER_ACTION
shortly.)
2. Create Action Types
Next, we define types for our specific actions. Define each action type as follows:
export interface IncrementAction {
type: TypeKeys.INC;
by: number;
}
The important point here is the definition of type
. Note that we’re defining type
to have the type TypeKeys.INC
. This tells TypeScript that all IncrementAction
have exactly one value as their type, enabling TypeScript to prove that if we test action.type == TypeKeys.INC
, we will have a by
property that is a number
.
How additional properties are organized is irrelevant – you can use flux standard actions with this technique as well.
3. Create a Type Representing All App Actions
Next, define a type representing all actions in your application by simply or-ing together all of your action types:
export type ActionTypes =
| IncrementAction
| DecrementAction
| OtherAction;
We tend to do this at the top of our actions file. TypeScript types can refer to types defined later in the file without issue.
4. Create an “Other” Action Type
The list of actions we handle in our reducers is almost never the complete list of actions flowing through Redux. Third-party plugins and Redux built-in actions happen as well, and our reducers need to handle them appropriately. It’d be nice to get help from TypeScript so that we don’t forget.
Our preferred approach for this is to define an OtherAction
type (which we never dispatch) that lives in our ActionTypes
, so TypeScript will warn us if it’s not handled.
export interface OtherAction {
type: TypeKeys.OTHER_ACTION;
}
We’ve stated here that OtherAction
has a type
with the value TypeKeys.OTHER_ACTION
. This is a useful fiction. No action with type
”__any_other_action_type__"
is ever dispatched.
Instead, we always make TypeScript happy in our reducers by including a default
case. TypeScript thinks it’s there to handle OtherAction
, but it’s really there for everything else.
Interlude: An Example reducer
With this foundation we can define reducers in terms of our actions, such as the following for a simple counter that increments/decrement a value:
function counterReducer(s: State, action: ActionsTypes) {
switch (action.type) {
case Actions.TypeKeys.INC:
return { counter: s.counter + action.by };
case Actions.TypeKeys.DEC:
return { counter: s.counter - action.by };
default:
return s;
}
}
TypeScript understands our types in a deep way. It’s able to prove that, within the INC
case, our action
is an IncrementAction
and therefore has a by
property. That property is not availability in our default
branch, as it’s not common to other actions.
Additionally, our default
case is enforced by TypeScript because of our OtherAction
type. Without that, TypeScript would think our case statement exhaustive, when it is not.
5. Action Builders
Action builders are also simple – just regular functions. We do recommend explicitly typing the return value so that TypeScript will check the action builder for compliance with the type:
const incrementCounter = (by: number): IncrementAction => ({
type: TypeKeys.INC,
by
});
And that’s it! Fully typed actions and reducers for Redux with minimal boilerplate.
Enums are only allowed to define a set of named numeric constants
Hi there,
This used to be the case, but string enums are supported as of 2.4. See the release announcement:
https://blogs.msdn.microsoft.com/typescript/2017/06/27/announcing-typescript-2-4/#string-enums
Drew
Thanks, I really like the pattern.
However it seems the builder is really not that useful considering the added complexity.
const incrementCounter = actionBuilder(TypeKeys.INC)(“by”);
vs
const incrementCounter = (by: number): IncrementAction => ({ type: TypeKeys.INC, by });
Thanks, Tom.
Yeah, perhaps you’re right about the actionBuilder, esp. given that way of writing the builder functions. I’d been using classic JS functions for builders out of habit, but switching them out for arrow functions helps with the noise a lot. Thanks for the feedback.
you should consider updating your article to reflect this. I’d like the share it as a reference to my team but the added complexity from the builder functions is a bit off-putting. (arrow: function)=> {return ‘FTW’}
Agreed. Was oohing and aahing at everything until I reached the section on Action Builders. Maybe even go so far as to remove Action Builders from this article entirely (you could always create a separate article that simply explores it as a concept?). Whilst I’d hate to see codebases using that curried ActionBuilder pattern, the research you’ve done on string enums for action types is excellent — I hope to see it widely adopted.
Done.
Done. :-)
Very good post, had a well-received presentation on a local Meetup using your typed actions along with typed forms from https://medium.com/@steida/the-boring-react-redux-forms-a15ee8a6b52b, for a really nice type safe form development experience.
Also agree that the actionBuilder is a bit too magic, thanks for updating the blog post with regular arrow function action creators.
Hi Drew,
Thanks for the tips, really nice way of putting it :)
Just out of curiosity can your ActionBuilder be seen somewhere?
Cheers
Howdy, I tried this out but got these maddening errors:
“`ts
export enum TypeKeys {
SET_LOADING = ‘SET_LOADING’,
}
function setLoading(loading: boolean): Actions.SetLoadingAction {
const type: Actions.TypeKeys.SET_LOADING = ‘SET_LOADING’;
return {type, loading}
}
“`
Type ‘{ type: “SET_LOADING”; loading: boolean; }’ is not assignable to type ‘SetLoadingAction’.
Types of property ‘type’ are incompatible.
Type ‘”SET_LOADING”‘ is not assignable to type ‘TypeKeys.SET_LOADING’.’
I can see I missed the point of string enums. Instead of sending the string literal value in the action, I instead lookup the value from the enum: {type: Actions.TypeKeys.SOME_ACTION}.
Hope that helps someone visiting from Google.
Glad you sorted it out, Benny! My original approach to actions/reducers in typescript (linked to at the start of the article) used literal string types, but you tend to run into some undesirable behavior if you go that route
(Typescript’s freshness behavior, which can be surprising to typescript newcomers and require redundant type declarations).
I’m sort of having the same issue and I’m confused… In:
Actions.TypeKeys.INC
Where does Actions come from?
Hi Robert,
The
Actions
in this case would be from something likeimport * as Actions from 'client/actions'
.One simple suggestion is to create a generic interface for action type:
interface BaseAction {
type: T,
by: U
};
Then IncrementAction becomes:
interface IncrementAction extends BaseAction {}
and DecrementAction becomes:
interface DecrementAction extends BaseAction {}
Silly editor stripped the generics thinking they were tags…but you get the picture.
Something like this would be a useful pattern if you’re using FSA or something, for sure.
In this particular example, hese actions happened to both have a `by` property by coincidence – it’s not something that I was thinking would generalize to all actions.
Nice article! Just a minor thing, but defining the TypeKeys is not necessary for this technique to work, tsc will notify you if you want to use a string which is not used as a type. It is still nice though to store your strings in constants.
Thanks, Mark! The string enum tends to work better than literal string types due to TypeScript’s freshness behavior. It’s very easy to end up in situations where TS widens a literal to all of `string` when you really want to preserve the fact that it was some specific string. String enums, in addition to giving you constants, help avoid some of these situations and make things a little easier to deal with.
More about freshness: https://basarat.gitbooks.io/typescript/content/docs/types/freshness.html
To be clear, I agree with it being a best practice, I was just trying to point out that saying ‘important for the rest of the technique’ is not true :-)
I’m aware of the concept of freshness in TypeScript, but can’t really think of how it can cause you problems in this scenario.
Hi Drew,
I think it is even simpler if you use classes instead of interface for Actions.
You don’t need the incrementCounter function if you define the actions as this:
export class IncrementAction {
type = TypeKeys.INC;
constructor(public by: number) {}
}
then create the action by using new: new IncrementAction(1);
Oups, sorry, we need to keep type safety:
export class IncrementAction {
type: TypeKeys.INC = TypeKeys.INC;
constructor(public by: number) {}
}
That would certainly work. However, there is downside to this approach.
With classes, you need to use the `new` keyword everywhere you’re dispatching one of these actions. This (a) clutters up the component dispatching the action and (b) pushes implementation details of your actions down into your component layer, because you’ve now coupled your action instantiation to the `class` language mechanism. You’d be able to tell which actions are data and which are e.g. thunks just by looking at the dispatching code. I prefer to keep my front-end more decoupled from these decisions.
(You could also wrap the class instantiation in an action builder, but then you’re really not gaining much through the use of `class` to begin with.)
On a separate-but-related note, we’re also mostly avoiding the use of `class` in general unless we’re explicitly programming in an OO style. We’re doing this sparingly, however, so not really using `class` much in practice. YMMV.
Drew, thanks for the post. How would you incorporate asynchronous thunk actions into this model? Seems like the actions would need to be a const or a class in order to have functions that dispatch events.
Thunks just work as normal – some of our action builders return thunks instead of action objects, and we just use our other action builders with the `dispatch` passed to the thunk. There really is no impact, as the differences from untyped redux are limited to the implementations of reducers and action builders.
Redux saga is similar – no impact, but with sagas we use our `TypeKeys` enum with `takeEvery` etc. (Sagas adds some additional wrinkles because `yield` syntax is always dynamically typed in TypeScript, but that’s really a more general issue independent of action typing.)
Very helpful. Thank you.
Suggestion: using singular names for enums, not plural. Enum values are enumerating the candidates for a category. SEX.male makes more sense than SEXES.male.
Drew, thanks for sharing the post!
I think I’ve found a way to solve this problem with less boilerplate code and more type-safety while even allowing more flexibility (different types of action payloads for related action reducer functions).
I just open-sourced my solution here: https://github.com/atsu85/redux-actions-ts-reducer#more-information – You’ll find examples, instructions and tests from there. Let me know what You think!