Skip to content

Strong Typing the State and Actions in NgRx

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

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 {
  [id: string]: Photo;
}

export const photoFeatureKey = 'photo';

export interface PhotoRootState {
  [photoFeatureKey]: PhotoState;
}

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 is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co