Strong Typing the State and Actions
When working with NgRx store, it is highly recommended to provide strong and explicit types for both the State and Actions. This becomes even more significant as our application will inevitably grow, which means it will need more features and almost certainly some refactoring along the way. This is where strong types might make this process easier and safe.
I'll base this article on a simple Angular app where we can display a list of photos that then can be liked or disliked. You can find the source code of this application on my GitHub repo. If you want to follow this article's code, please clone the repository and checkout strongTypingState_entryPoint
tag.
git clone git@github.com:ktrz/introduction-to-ngrx.git
git checkout strongTypingState_entryPoint
After cloning, install all the dependencies:yarn install
You can see the example app by running:yarn start -o
Typing Actions
With the latest version of NgRx, typing actions is very straight forward. Quoting the docs:
The createAction function returns a function, that when called returns an object in the shape of the Action interface. The props method is used to define any additional metadata needed for the handling of the action. Action creators provide a consistent, type-safe way to construct an action that is being dispatched.
Creating actions for liking and disliking a photo could look like this:
// src/app/store/photo.actions.ts
import {createAction, props} from '@ngrx/store';
export const likePhoto = createAction(
'[Photo List] Like Photo',
props<{id: string}>()
);
export const dislikePhoto = createAction(
'[Photo List] Dislike Photo',
props<{id: string}>()
);
This creates concise and type-safe actions and action creators all-in-one. It also doesn't produce as much boilerplate as the class approach, which was used in previous versions of NgRx (and can still be found in many production code repositories):
// src/app/store/photo.actions.ts
import {Action} from '@ngrx/store';
const enum PhotoActionTypes {
LikePhoto = '[Photo List] Like Photo',
DislikePhoto = '[Photo List] Dislike Photo'
}
class LikePhoto implements Action {
readonly type = PhotoActionTypes.LikePhoto;
constructor(public readonly id: string) {}
}
class DislikePhoto implements Action {
readonly type = PhotoActionTypes.DislikePhoto;
constructor(public readonly id: string) {}
}
export type PhotoActions = LikePhoto | DislikePhoto;
In this example, we need to create classes for each action that act as action creators. However, it is good practice to extract all possible action types into an enum or a set of consts and a separate type union PhotoActions
to use later ie. in reducers. All this behavior is neatly packed into the createAction
utility function so for creating new actions, I highly suggest using it.
Typing State
When it comes to typing state, it's a good practice to type every slice of the state containing a specific feature separately. A good place to include it is a reducer file which will handle this specific slice of the state. For larger projects, you can also keep your state types in a separate file ie. src/app/store/photo.state.ts
// src/app/store/photo.state.ts
export interface Photo {
id: string;
title: string;
url: string;
likes: number;
dislikes: number;
}
export interface PhotoState {
}
export const photoFeatureKey = 'photo';
export interface PhotoRootState {
}
Typing rest of NgRx chain (implicitly)
By having both State and Actions strongly typed, all created reducers, selectors, and effects can easily infer further types and keep the rest of our NgRx chain type-safe.
import {createReducer, on} from '@ngrx/store';
import {dislikePhoto, likePhoto} from './photo.actions';
import {PhotoState} from './photo.state';
const initialState: PhotoState = {};
export const photoReducer = createReducer(
initialState,
on(likePhoto, (state, action) => ({
...state,
[action.id]: {
...state[action.id],
likes: state[action.id].likes + 1
}
})),
on(dislikePhoto, (state, action) => ({
...state,
[action.id]: {
...state[action.id],
dislikes: state[action.id].dislikes + 1
}
}))
);
By providing initialState
to createReducer
utility function, our photoReducer
is strongly typed to operate only on PhotoState
type.
Each on(...)
call uses a TypeScript type inference from the provided action (likePhoto
, dislikePhoto
) so that
on(likePhoto, (state, action) => {/* ... /*})
is actually strongly typed as
// this is a bit simplified type than the actual inferred type
// for a sake keeping it easier to grasp
type LikeActionType = {id: string, type: '[Photo List] Like Photo'}
on(likePhoto, (state: PhotoState, action: LikeActionType): PhotoState => {/* ... /*})
The same rules apply to building selectors from our state
// src/app/store/photo.selectors.ts
import {createFeatureSelector, createSelector} from '@ngrx/store';
import {photoFeatureKey, PhotoRootState, PhotoState} from './photo.reducer';
const selectPhotoFeature = createFeatureSelector<PhotoRootState, PhotoState>(photoFeatureKey);
export const selectPhotos = createSelector(selectPhotoFeature, state => Object.keys(state).map(key => state[key]));
export const selectPhoto = createSelector(selectPhotoFeature, (state: PhotoState, props: {id: string}) => state[props.id]);
By providing strong explicit typings for selectPhotoFeature
, TypeScript will usually be able to infer types for all the other selectors derived from it. When creating a new derived selector:
export const selectPhotos = createSelector(selectPhotoFeature, state => Object.keys(state).map(key => state[key]));
it is equivalent to explicitly typing everything like so
export const selectPhotos = createSelector<PhotoRootState, PhotoState, Photo[]>(selectPhotoFeature, (state: PhotoState): Photo[] => Object.keys(state).map(key => state[key]));
Not everything use case can be inferred automatically but usually, a small hint for a TS compiler is enough
export const selectPhoto = createSelector(selectPhotoFeature, (state, props: {id: string}) => state[props.id]);
state
param can't be automatically inferred and has any
type by default. Angular will complain about it in a strict mode, so in order to complete the typing, we can explicitly add the proper PhotoState
type here.
export const selectPhoto = createSelector(selectPhotoFeature, (state: PhotoState, props: {id: string}) => state[props.id]);
Benefits
In conclusion, by providing strong typing for just Actions and State, we get typings in other parts of NgRx chain usually for free (or by providing minimal hints for the TS compiler). This means that we can benefit both from our IDE auto completion when writing the code. It also provides us with a safety net in case of doing some refactoring or adding new functionality to the state. For example, if we modify the shape of the state in order to accommodate for a new feature, we will immediately be notified by the TS compiler or our IDE of which other parts of the app chain are affected. This way we can review all of them more easily. When combining that with a high test coverage, we can have a good level of confidence to modify the code without breaking anything in the process.
You can find the code for this article's end result on my GitHub repo. Checkout strongTypingState_ready
tag to get the up-to-date and ready-to-run solution.
If you have any questions, you can always tweet or DM me @ktrz. I'm always happy to help!
This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.
This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.
Top comments (0)