The main problem that has to be solved is how to show TypeScript what the concrete action type is after checking the type
property of Redux actions.
Straightforward Solution
The most straightforward way to do this is creating a type for every action and using type guards to distinguish them.
Type guards are boolean functions that check if a parameter is a specific type, indicated by the return value being parameter is Type
.
Used in a condition, TS assumes the variable passed to the type guard is of the type the guard checks for. Only in the true branch of course.
An example solution with type guards:
interface Action {
type: string;
}
interface StartAction extends Action {
date: Date;
}
interface SuccessAction extends Action {
data: number;
}
interface ErrorAction extends Action {
error: string;
}
export const createStart: () => StartAction = () => ({
type: 'Start',
date: new Date()
});
export const createSuccess = (data: number): SuccessAction => ({
type: 'Success',
data
});
export const createError = (error: string): ErrorAction => ({
type: 'Error',
error
});
// TYPE GUARDS
const isStartAction = (action: Action): action is StartAction =>
action.type === 'Start';
const isSuccessAction = (action: Action): action is SuccessAction =>
action.type === 'Success';
const isErrorAction = (action: Action): action is ErrorAction =>
action.type === 'Error';
export interface State {
lastStarted?: Date;
data?: number;
error?: string;
}
const defaultState: State = {
lastStarted: undefined,
data: undefined,
error: undefined
};
const reducer = (state = defaultState, action: Action) => {
if (isStartAction(action)) {
return {
lastStarted: action.date,
data: undefined,
error: undefined
};
}
if (isSuccessAction(action)) {
return {
data: action.data,
error: undefined
};
}
if (isErrorAction(action)) {
return {
data: undefined,
error: action.error
};
}
return state;
};
This works, but is quite verbose.
For every action, it's necessary to define
- The action type
- The action creator
- The type guard
And whenever an action changes, a property is added/removed/changed, it has to be adapted on 2 places, the specific action interface and the action creator.
It's possible to get rid of both, but that requires a little detour first.
Inferring Concrete Types from Unions
TS has a neat type inference feature. Under specific circumstances, it can infer the concrete type from a union. What I mean is this
const x: A | B = someObject;
if (someCondition) {
// x is of type A here
}
For this to work A
and B
must have a property in common. In Redux actions it would be type
. This is necessary so that TS knows this property can be accessed in the condition.
Additionally the type
property must be a literal type, for example a literal string (e.g. 'Start'
) or number (e.g. 123
).
This type is checked in the condition. Since each type's type
property can only be a specific value, TS can infer the type based on the given value.
interface A {
type: 'Start';
propA: number;
}
interface B {
type: 'End';
propB: number;
}
if (x.type === 'Start') {
// x is of type A here
}
For this reason it's necessary that the type
properties are literal types. If they were a generic string
, there'd be nothing to go on for type inference.
Using Unions to Infer the Action Type
Armed with that knowledge, it's possible to get rid of the type guards, in exchange for adding ActionTypes
, which is a union of all action types handled in that reducer.
interface StartAction {
type: 'Start';
date: Date;
}
interface SuccessAction {
type: 'Success';
data: number;
}
interface ErrorAction {
type: 'Error';
error: string;
}
type ActionTypes = StartAction | SuccessAction | ErrorAction;
export const createStart = (): StartAction => ({
type: 'Start',
date: new Date();
});
export const createSuccess = (data: number): SuccessAction => ({
type: 'Success',
data
});
export const createError = (error: string): ErrorAction => ({
type: 'Error',
error
});
export interface State {
lastStarted?: Date;
data?: number;
error?: string;
}
const defaultState: State = {
data: undefined,
error: undefined
};
export default (state = defaultState, action: ActionTypes) => {
if (action.type === 'Start') {
return {
lastStarted: action.date,
data: undefined,
error: undefined
};
}
if (action.type === 'Success') {
return {
data: action.data,
error: undefined
};
}
if (action.type === 'Error') {
return {
data: undefined,
error: action.error
};
}
return state;
};
Removing Action Types with ReturnType
The last improvement to arrive at the final example is automatically inferring action types based on the return value of action creators.
This can be done with the helper type ReturnType
.
As the name says, it is a generic type that, if passed a function, returns the type of the returned value. For example
type F = (p1: string, p2: number) => boolean;
type T = ReturnType<F>;
// T = boolean
If you're interested how it works, check out my other blogpost List of Built-In Helper Types in TypeScript.
By using that helper type it's possible to create the union type directly from the return types of the action creators.
One thing to notice here is that the returned objects define the type
like this: 'Start' as 'Start'
.
This is so the inferred type is of the literal string ('Start'
), because by default, when assigning a string, TS infers it to be of type string
.
type ActionTypes =
| ReturnType<typeof createStart>
| ReturnType<typeof createSuccess>
| ReturnType<typeof createError>;
export const createStart = () => ({
type: 'Start' as 'Start',
date: new Date();
});
export const createSuccess = (data: number) => ({
type: 'Success' as 'Success',
data
});
export const createError = (error: string) => ({
type: 'Error' as 'Error',
error
});
export interface State {
lastStarted?: Date;
data?: number;
error?: string;
}
const defaultState: State = {
data: undefined,
error: undefined
};
export default (state = defaultState, action: ActionTypes) => {
if (action.type === 'Start') {
return {
lastStarted: action.date,
data: undefined,
error: undefined
};
}
if (action.type === 'Success') {
return {
data: action.data,
error: undefined
};
}
if (action.type === 'Error') {
return {
data: undefined,
error: action.error
};
}
return state;
};
This is the final example, as concise and with the least typing effort possible.
All solutions are valid though, and depending on the rest of the codebase, the team and your preference you might want to choose a more explicit (first) way to implement type-safe reducers.
Top comments (1)
Shameless plug: it was a bit of a pain point for me ensuring type safe reducers in a few projects, so I wrote a library for that. It infers types in reducers from action creators.
npmjs.com/package/@reduxify/utils