For fans of Redux and Typescript like myself, the release of Redux 4 was a highly anticipated event, and it did not disappoint. The new enhanced typing definitions help to provide a nice and strongly typed Redux setup, minimizing the number of runtime errors. This article will give you a different perspective on the many ways you can structure the app. Feels like TLDR? Skip it and check the source, here.
Note: This tutorial assumes a baseline familiarity with the following topics on Redux and Typescript on the reader's part.
To get started, let’s define some basic action types. I personally like to have two, an Action and PayloadAction
// app.actions.ts
import { Action as ReduxAction } from 'redux';export type Action<Type, Meta = void> = ReduxAction<Type> & { meta?: Meta;};
export type PayloadAction<Type, Payload, Meta = void> = Action<Type, Meta> & { readonly payload: Payload;
};
One option is to reuse Action from redux, even though we could write our own easily. In my experience, designing it with an additional meta optional property facilitates further introduction of analytics logs. Some colleagues from Rangle did a great job on putting together an analytics library called redux-beacon, check it out!
Based on these two generic types, we can write two generic functions to handle the creation of all actions, which helps to unify the shape of actions running through the system.
// app.types.ts
import { Action, PayloadAction } from './app.types';
export const createAction = <Type extends string, Meta>(type: Type, meta?: Meta): Action<Type, Meta> =>
({ type, meta });
export const createPayloadAction = <Type extends string, Payload, Meta>(
type: Type,
payload: Payload,
meta?: Meta,
): PayloadAction<Type, Payload, Meta> => ({
...createAction(type, meta),
payload,
});
A caveat in Typescript due widening of types (check TypeScript 2.1 release notes), is that in order to further use enums as action types, we need to restrict the generic argument Type to something that is a string, so that the types can be assignable.
A common pattern I have been using is to define a state type that represents the global app structure
// app.actions.ts
export type IState = {
todo: import('../todo/todo.types').ITodoState;
// ... more state slices
};
Note that with Typescript 2.9 we can import types without importing the module itself, that will be handy when app level files import module files that may be lazy loaded. Using a standard Todo example, let’s add some typings to illustrate how we could design ITodoState:
// todo.types.ts
import { PayloadAction } from '../app/app.types';
export type ITodo = { text: string; isCompleted: boolean; };
export type ITodoState = { list: ITodo[]; };
export enum ITodoActionTypes {
ADD_TODO = 'ADD_TODO',
REMOVE_TODO = 'REMOVE_TODO',
UPDATE_TODO = 'UPDATE_TODO',
}
export type IAddTodoAction = PayloadAction<ITodoActionTypes.ADD_TODO, string>;
export type IRemoveTodoAction = PayloadAction<ITodoActionTypes.REMOVE_TODO, number>;
export type IUpdateTodoTextActionPayload = ITodo & { index: number };
export type IUpdateTodoAction = PayloadAction<ITodoActionTypes.UPDATE_TODO, IUpdateTodoTextActionPayload>;
export type ITodoActions = IAddTodoAction | IRemoveTodoAction | IUpdateTodoAction;
Using the generic definitions PayloadAction and Action as a base for the types, more specific actions can be defined. In the end, we want to be able to define a type that represents all possible actions that the reducer can handle. We can accomplish this by using a union type, creating ITodoActions. In this example, ITodoActions does that job by being a union of all possible actions this module needs.
With all these typing definitions for our Todo module, what is left is a reducer and some action creators. Leveraging the generic functions written above, here is how they can be defined:
// todo.actions.ts
import { IAddTodoAction, IRemoveTodoAction, ITodo, ITodoActionTypes, IUpdateTodoAction} from './todo.types';
import { createPayloadAction } from '../app/app.actions';
export const addTodoAction = (text: string): IAddTodoAction => createPayloadAction(ITodoActionTypes.ADD_TODO, text);
export const updateTodoAction = (index: number, todo: ITodo): IUpdateTodoAction => createPayloadAction(
ITodoActionTypes.UPDATE_TODO,
{ index, ...todo }
);
export const removeTodoAction = (index: number): IRemoveTodoAction => createPayloadAction(
ITodoActionTypes.REMOVE_TODO,
index
);
Due the generic arguments, which the app action creators (createPayloadAction, createAction) benefit from, and as long as the right parameter types are given to them, the returning types PayloadAction and Action are assignable to the more specific actions. For instance, IAddTodoAction requires type as ITodoActionTypes.ADD_TODO and payload as string.
// todo.reducer.ts
import {
IRemoveTodoAction,
ITodoActions,
ITodoActionTypes,
ITodoState,
IUpdateTodoAction,
IAddTodoAction,
} from './todo.types';
import { Reducer } from 'redux';
import { ensureNever } from '../utils/typescript.utils';
export const initialTodoState: ITodoState = { list: [] };
const addTodo = (state: ITodoState, action: IAddTodoAction): ITodoState => { /* ... */ };
const removeTodo = (state: ITodoState, action: IRemoveTodoAction): ITodoState => { /* ... */ };
const updateTodo = (state: ITodoState, action: IUpdateTodoAction): ITodoState => { /* ... */ };
export const todoReducer: Reducer<ITodoState, ITodoActions> = (state = initialTodoState, action) => {
switch(action.type) {
case ITodoActionTypes.ADD_TODO:
return addTodo(state, action);
case ITodoActionTypes.REMOVE_TODO:
return removeTodo(state, action);
case ITodoActionTypes.UPDATE_TODO:
return updateTodo(state, action);
default:
ensureNever(action);
return state;
}
};
We are not going to focus on how we handle the actions, instead, we will have a look at how Typescript can infer and restrict the corresponding type definitions for each case in the switch statement. This happens because todoReducer is typed as a Reducer<ITodoState, ITodoActions>, which automatically makes the state and action arguments be ITodoState and ITodoActions respectively. Knowing that the action’s type restricts the switch cases to a set of predefined actions, and Typescript reinforces this by throwing errors if any of the switch cases include actions that aren’t in the enum we previously defined ITodoActionTypes.
Another thing I learned from a friend is that we can ensure never is the action type for default cases, this will enforce every new action type that is added in the combinations of all actions (ITodoActions) to have a corresponding switch case.
// typescript.utils.ts
export const ensureNever = (action: never) => action;
Going back to the app overall types and thinking about lazy loading reducers, the app would need more flexible types when defining its state and reducers. This can be trivially achieved by using Partial
// app.types.ts
export type IState = {
todo: import('../todo/todo.types').ITodoState;
};
export type IReducers = ReducersMapObject<IState>;
export type ILoadedState = Partial<IState>;
export type ILoadedReducers = Partial<IReducers>;
The last interesting part is a way we could structure the root reducer creation, so that additional reducer state slices can be added in programmatically. It can be achieved by leveraging the replaceReducer function from redux, by simply storing the dynamically added reducers.
// app.reducer.ts
import { combineReducers, Reducer, Store } from 'redux';
import { ILoadedReducers, ILoadedState } from './app.types';
import { todoReducer } from '../todo/todo.reducer';
let asyncReducers: ILoadedReducers = {};
export const createRootReducer = (): Reducer<ILoadedState> => {
const initialReducers: ILoadedReducers = {
todo: todoReducer,
...asyncReducers,
};
return combineReducers(initialReducers);
};
export const injectReducer = (store: Store<ILoadedState>, reducers: ILoadedReducers) => {
asyncReducers = { ...asyncReducers, ...reducers };
store.replaceReducer(createRootReducer());
};
This way, when a module is lazy loaded, all that is required to include that reducer into the app store is calling injectReducer. Because of the typings, injectReducer will only accept keys and reducers that matches the IState definition, ensuring no random and unknown reducer is used. For instance, trying to add this following reducer for the todo state slice would result in an error:
import { PayloadAction } from 'app/app.types';
import { store } from 'app/app.store';
import { injectReducer } from 'app/app.reducers';
type IFakeState = { fake: boolean };
type IFakeActions = PayloadAction<'fakeType', string>;
injectReducer(store, { todo: (state: IFakeState, action: IFakeActions) => state });
This happens since the expected type for todo is Reducer<ITodoState, ITodoActions> and not Reducer<IFakeState, IFakeActions>. This is a nice compile time validation, especially in large-scaled apps with several developers contributing to the same code base.
Finally, hook createRootReducer by calling it when creating the store.
import { createStore, Store } from 'redux';
import { createRootReducer } from './app.reducer';
import { IState } from './app.types';
export const store: Store<IState> = createStore(createRootReducer());
Hopefully, this setup gave you another perspective on how to safely inject new reducers that are lazy loaded, as well as how to leverage some basic tricks of Typescript with intersection types and the basics of generics. Enjoy this post? Stay tuned for my next post on Typescript, coming soon!