TS intersection types, JS prototypes and NgRx selectors

Lately I’ve been working in an Angular project using NgRx for state management and I came across a scenario that I found to be common: exposing combined state from the store.

Lets say we have an application that manages products, providing at least two different views of those products: a ProductSummary list and a ProductDetails page. In addition, the application should “lock” a product for modification while a “modify” operation is ongoing. In this scenario, the state on the NgRx store could be represented as follows:

export interface State {
  products: ProductSummary[];
  selectedProduct: ProductDetails | null;
  lockedProductIds: string[];
}

Given this state, I’d like to disable some action buttons both on the list and on the details page while a product is locked (due to some pending action). Before diving into the solution I implemented, lets think of same possibilities:

  1. Define separate selectors for the product summary/details and the “is locked” flag. This works alright for the product details component, which would likely have two @Input properties, based on two different observables. This approach is used on the official NgRx sample application and has the advantage of only triggering state changes on the needed part of the state. However, if you think on the product list, it becomes messy, as we’d need to expose a third selector for the “set of locked products” and build the list’s UI based on two arrays (products and locked products).
  2. Define new “view model” types – ProductSummaryWithState and ProductDetailsWithState – that extend the existing product types and expose them via selectors, one for the list and one for the selected product.This is more cohesive but might mean more memory usage due to a copy from ProductSummary to ProductSummaryWithState, unless we use some tricks (more on this in a bit). Also, if the same concept is needed for other parts of the application, we’ll need to create more types such as SomeOtherEntityWithState.
  3. Define new view model types that wrap the product and the locked state, and define selectors that build up those models on the fly. This is good in terms of cohesion and memory usage, but the component templates look uglier, as we’d need to write item.product.nnn everywhere.

The solution I ended up implementing takes a bit from all the previous options and tries to combine them into a better approach. First I defined a new interface to represent the generic “control state”:

export interface ControlState {
  locked: boolean;
}

Then, instead of defining explicit new types to combine the control state with the products, I used Typescript’s intersection types. This means that when selecting state from the store I want to get Observables like the ones illustrated below.

products$: Observable<ProductSummary & ControlState> = this.store.pipe(select(getProducts));

selectedProduct$: Observable<ProductDetails & ControlState> = this.store.pipe(select(getSelectedProduct));

With this approach the templates can use both product.name and product.locked on the same object and that the TS compiler is fine with passing such an object in places that get either a ProductSummary or a ControlState.

To expose the data in this form we need to write selectors that combine a product and the control state (the same problem as described earlier on alternative 2). A first approach to this is using TS’s spread operator.

export const getSelectedProduct = createSelector(
  (state: State) => state.selectedProduct,
  (state: State) => state.lockedProductIds,
  (selectedProduct, lockedProductIds) => {
    if (selectedProduct === null) {
      return null;
    }
    const o = {
      ...selectedProduct,
      locked: !!lockedProductIds.find(id => id === selectedProduct.id)
    };
    return <ProductDetails & ControlState>o;
  }
);

While this works, it means that we actually copy the product object. This would be worst for the list page. A better option is to rely on Javascript prototypes!

(selectedProduct, lockedProductIds) => {
    if (selectedProduct === null) {
      return null;
    }
    const o = <ControlState>Object.create(selectedProduct);
    o.locked = !!lockedProductIds.find(id => id === selectedProduct.id);
    return <ProductDetails & ControlState>o;
  }

We create a new object for the control state that uses the product as its prototype. This means that when accessing the type intersection object, all the product properties are actually delegated to the prototype (the actual product object), but this is fine because the interface is still correct.

With this approach we avoid explicitly defining new types for “view models” and unnecessary memory usage when projecting state from the store. Hope this helps!

Advertisement