TypeScript Redux Action from User Defined Guard to Discriminated Union
Posted on: 2018-02-20
Using TypeScript with Redux bring many questions about what really is passing around. Enforcing a strongly typed model and keeping the flexibility while using Redux is a challenge that doesn't have a single solution. In this article, we will see the first approach using user-defined guard and see how to transform this one to use a discriminated union.
Redux is all about sending a message from the React component to the Redux store. In the middle, you can manipulate the message sent to middleware. The message sent is called "action". The action contains the payload that can be analyzed in the Redux's flow and transformed to finally be persisted in the single store. A common pattern is to send the payload by using a function that creates that message which is then passed to Redux's dispatch which will send the message in its normal flow. For example, in your React component, connected with React-Redux, you can dispatch a function that returns your payload. The payload should contain a unique identifier and the payload at the minimum. The unique identifier allows middlewares and reducers to know if they should catch the message or just let it goes. The payload is to be manipulated by the middlewares or stored in the store by the reducers. The first approach is, with a user-defined guard, is to create a reusable generic action which will be the envelope for your message.
const MY_OBJECT_UNIQUE_ACTION_ID_HERE = "MY_OBJECT_UNIQUE_ACTION_ID_HERE";
// Reusable generic action
export interface Actions<T> extends Action {
readonly type: string;
payload: T;
}
// Example of a function that creates an action which is used in the Redux's dispatch
export function createActionToSaveMyObject(data:MyObject): Actions<MyObject> {
return {
type: MY_OBJECT_UNIQUE_ACTION_ID_HERE,
payload: data };
}
// The dispatch done through the React-Redux's API
dispatch(createActionToSaveMyObject({id:1, name:"test"}));
Inside the middleware, to consume the action, you need to do two things. Like any Redux code, you need to check the type and compare it against the unique constant for the action. However, since we are using a generic action, it needs to have all possible type as a union inside the generic. This cause the action to have a payload not very strongly typed. It is but could be any of the type specified in the union of the generic type of Actions. To narrow down, we need to use a user-defined guard. We could have a user-defined guard function for each type, or we could cheat and have one type that just cast it to the desired type.
// Middleware example
export interface ExtendedMiddleware<StateType> extends Middleware {
<S extends StateType>(api: MiddlewareAPI<S>): (next: Dispatch<S>) => Dispatch<S>;
}
export const yourMiddleware: ExtendedMiddleware<AppReduxState> =
<S extends AppReduxState>(api: MiddlewareAPI<S>) => (next: Dispatch<S>) => <A extends Actions<MyObject | MyObject2 | MyObject3 >>(action: A): A => {
if (action.type === MY_OBJECT_UNIQUE_ACTION_ID_HERE && isGenericAction<MyObject>(action)) {
// Logic that can access action.payload that is strongly typed with MyObject
}
});
// Reducer is using the same logic
export function youReducer( state: AppState = initialState, action: Actions<MyObject | MyObject2 | MyObject3 >> ): AppState {
if (action.type === MY_OBJECT_UNIQUE_ACTION_ID_HERE && isGenericAction<MyObject>(action)) {
// Logic that can access action.payload that is strongly typed with MyObject } }
// The cheat:
export function isGenericAction<T>(obj: any): obj is Actions<T> {
const castedObject = (obj as Actions<T>);
return castedObject.payload !== undefined && castedObject.type !== undefined;
}
As you can see, the problem with this approach is that is that we must use everywhere the function that will return the narrowed type. It's convoluting and also bring some issues. For instance, if you use the function and pass the generic type to something else that really what is the object, the cast will allow it and indicate to TypeScript the wrong type. isGenericAction(objectType2) will indicate to TypeScript that the object passed is of type 1 because the condition to evaluate is weak and always true for any time that extends the generic actions interface created. This approach has the advantage to be succinct anytime you need to create a new action which is to create an action creator function, an unique constant and a check when needed.
An alternative is to use a discriminated union. This approach has more boilerplate but will eliminate the need to use a function to coerce a narrowed type. TypeScript will take care of doing it. The main advantage of using a discriminated union is that it is strongly typed. The disadvantage is that we are losing some flexibility since we cannot have anymore the member "type" to be a string. It needs to use a discriminated field which will be of the type of a unique string and not a string. For instance, the type will be the name of the constant as well as its values instead of being of type string with the value of the constant.
const MY_OBJECT_UNIQUE_ACTION_ID_HERE = "MY_OBJECT_UNIQUE_ACTION_ID_HERE";
// Reusable generic action
export interface Actions<T> extends Action { payload: T; }
// Every action must have an interface with its discriminator
export interface ISaveMyObject extends Actions<MyObject> {
type: typeof MY_OBJECT_UNIQUE_ACTION_ID_HERE;
}
// Example of a function that creates an action which is used in the Redux's dispatch
export function createActionToSaveMyObject(data:MyObject): ISaveMyObject {
return {
type: MY_OBJECT_UNIQUE_ACTION_ID_HERE,
payload: data
};
}
// The dispatch done through the React-Redux's API
dispatch(createActionToSaveMyObject({id:1, name:"test"}));
So far, little changed. However, we see that we need to create an interface and set the type to a unique name. A good practice is to use the constant and with typeOf extract the string of it and assign it as a type. In the action creator function, you can assign the value of the constant. This might not look like a big drawback from user-defined guard, but it can be involving on big system with hundreds and hundreds of action. Nevertheness, middlewares and reducers are simplified. Since every action is having a unique interface with a unique discriminator under the common member "type", it's possible to check against the discriminator and TypeScript will know which interface it is associated with. Since every interface extends the actions that contain a strongly typed payload, the type is accessible directly.
export interface ExtendedMiddleware<StateType> extends Middleware {
<S extends StateType>(api: MiddlewareAPI<S>): (next: Dispatch<S>) => Dispatch<S>;
}
type AcceptedAction = MyObject | MyObject2 | MyObject3 ;
export const yourMiddleware: ExtendedMiddleware<AppReduxState> =
<S extends AppReduxState>(api: MiddlewareAPI<S>) =>
(next: Dispatch<S>) =>
<A>(action: A): A => {
const actionTyped = action as any as AcceptedAction;
if (actionTyped.type === MY_OBJECT_UNIQUE_ACTION_ID_HERE) {
// Logic that can access actionTyped.payload that is strongly typed with MyObject
}
});
// Reducer is very similar
export function yourReducer( state: AppState = initialState, action: Actions<A> ): AppState {
const actionTyped = action as any as AcceptedAction;
if (action.type === MY_OBJECT_UNIQUE_ACTION_ID_HERE) {
// Logic that can access actionTyped.payload that is strongly typed with MyObject
}
}
There is one dirty trick that you can see the action being cast to any
and cast to the list of accepted type. Ideally, the action would be of type of the union. However, Redux definition files require having a type A. Extending the type create confusion to the compiler which doesn't know which of the type and thus have the payload of all the unionized type.
In this article, we saw two function ways to handle action from React to Redux's state. The first one is using a user-defined guard which was quicker to write but loosely strongly typed. The second approach is more convoluted but is strongly typed and protect you from having a false sense of security that the code is typed with a specific type which may be deceptive. Neither of the design are peculiar but the latter is by experience worth the additional typing.