Article summary
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.
Great article, thanks.
I’m not quite sure why you mentioned React, as I didn’t see anything related to that?
Hi Lars,
This technique isn’t specific to React, just Redux – I mentioned it only as assumed context, given that Redux is almost exclusively used as a React state management system.
Drew
Thank you
And thanks for a great article – I really enjoy Redux
Nice! It would be interesting also if you could document how to deal with types + immutable records and types + combineReducers :)
Hi Remo,
Good questions. I plan to blog about this soon, but I’ve been using the pattern shown here for immutable models:
https://gist.github.com/dcolthorp/bba38d3522f08043ef757e36cf7fa3a2
While it requires basically duplicating your interface, you end up with an immutable.js record with typed read access and a typed `with` method for updates. It’s otherwise just a regular, dynamic Immutable.js record.
Regarding combineReducers, I think you can just make each function argument take the type of the action that block is intended to handle. So in our case the function associated with the `increment` case would just be declared to take an `IncrementAction`
Drew
https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.ReactServices/npm/redux-typed/src
Hi,
Thank you for your article!
I like the way to remove the need for magic strings in the reducer but cant seem to make a successful registration using mapDispatchToProps.
Just want to make sure that i´m doing this the correct way.
Do i need all of this?
export type INCREMENT_COUNTER = ‘App/INCREMENT_COUNTER’;
export const INCREMENT_COUNTER : INCREMENT_COUNTER = ‘App/INCREMENT_COUNTER’;
export type IncrementCounterAction = {
type: INCREMENT_COUNTER,
by: number
};
export const IncrementCounterAction = (by: number): IncrementCounterAction => ({
type: INCREMENT_COUNTER,
by,
});
Thanks a lot!
Best regards
Rob
Wouldn’t it be possible to use generics to better restrict how you consume actions, i.e.
“`
interface Action {
type: T;
[payload: string]: P;
readonly meta?: string;
}
“`
Are there any pitfalls here that I’m missing?