Narrowing type inside Observable<T>

Viewed 1172

Short explanation

I want to be able to write actions$.ofType<MyAction>().map(a => ...) instead of actions$.filter(a => a.type === ActionType.MY_ACTION).map((a: MyAction) => ...).

Background

I'm trying to save some boilerplate when working with Redux in Angular by following the pattern laid out in this article: https://spin.atomicobject.com/2017/07/24/redux-action-pattern-typescript/. The TL;DR is that you can use string enums for the type field on your actions to simplify reducer code without sacrificing type safety. It involves making action interfaces that look like this:

export enum ActionType {
    ADD = "Add",
    NEGATE = "Negate"
}

export interface AddAction extends Action {
    type: ActionType.ADD;
    amount: number;
}

export interface NegateAction extends Action {
    type: ActionType.NEGATE;
}

export type EveryAction = AddAction | NegateAction | DummyAction;

In reducers you can now write code like this:

switch (action.type) {
    case TypeKeys.ADD: {
        // The type of action is narrowed to AddAction here.
    }
    case TypeKeys.NEGATE: {
        // ...and to NegateAction here.
    }
}

Question

I'd like to apply a similar pattern when writing "epics" with redux-observable. I want a function ofType<T>() that simultaneously filters by the type value on the action object and narrows to the correct derived action type. Note that redux-observable already has a non-generic ofType(), which takes the type value as an argument, but: 1) I'd rather pass the Action interface type as a generic argument instead of the type value, and 2) the type of the action after calling the built-in ofType() is still just Action.

This is what I want the code to look like:

export class Epics {
    epics: Array<Epic<EveryAction, RootState>>;

    constructor() {
        this.epics = [
            this.afterAdd,
            this.afterNegate
        ];
    }

    public afterAdd = (actions$: Observable<EveryAction>) => actions$
        .ofType<AddAction>()
        .do(a => console.log(`Added ${a.amount} to value`)); // a is AddAction here

    public afterNegate = (actions$: Observable<EveryAction>) => actions$
        .ofType<NegateAction>()
        .do(a => console.log("Negated value"));
}

However, I can't figure out how to write ofType<T>(). I've been playing around with different TypeScript features like index types and keyof, but I can't get it to work. Is it possible?

I'd like the function to be ofType<T>(), but if it has to be ofType(ActionType.INCREMENT) then that's fine too, as long as it automatically narrows the action type without having to specify both the generic type and the the type key.

3 Answers

If you're willing to tweak your Actions to be classes, then you can take advantage of narrowing on class types and a definition of filter that admits a type guard. Note also that this ignores the pattern for pattern matching using the ActionTypes enum.

Foremost, an arrow function in filter won't narrow without an explicit type guard, even if it is wrapping a type guard, so a factory approach makes our lives much easier:

function ofType<S extends EveryAction>(b: new () => S): (a: EveryAction) => a is S) {
  return (a: EveryAction): a is S => a instanceof b;
}

With your Actions as classes with empty constructors, e.g.:

export class AddAction implements Action { // ...

Then usage is nice and short like you want:

public afterAdd = (actions$: Observable<EveryAction>) => actions$
   .filter(ofType(AddAction))
   .do(a => a.amount);

One approach with pattern matching that you want unfortunately doesn't work:

function ofType<S extends EveryAction>(t: ActionType): (a: EveryAction) => a is S) {
  return (a: EveryAction): a is S => a.type === t;
}

// used as:
(a as Observable<EveryAction>).filter(ofType(ActionType.ADD)) // S is still EveryAction :/

Unfortunately, TypeScript isn't that clever yet.

You can do what you want if you use classes as action creators.

Actions will be created as class instances, but the mechanism does not rely upon them remaining as class instances and does not use instanceof. Actions serialized, sent to the Redux DevTools and then replayed using time travel debugging will still work with the mechanism. Classes are only used so that there is a place to store a static copy of the type.

Anyway, this is what it looks like:

export enum ActionType {
  ADD = "Add",
  NEGATE = "Negate"
}

export class AddAction implements Action {
  static readonly type = "ADD";
  readonly type = "ADD";
  constructor (public amount: number) {}
}

Note that both the static and instance type properties are readonly. They need to be so that they are inferred as string-literal types - your reducer code indicates you are using a discriminated union to narrow the action.

For the ofType operator, a type guard needs to be used. Because the class contains the static type, you can implement the operator like this:

import { Observable } from "rxjs/Observable";
import { filter } from "rxjs/operator/filter";

interface ActionCtor<T extends string, A extends Action> {
  type: string;
  new (...args: any[]): A;
}

function ofType<T extends string, A extends Action>(
  this: Observable<Action>, creator: ActionCtor<T, A>
): Observable<A> {
  return filter.call(this, (a: Action): a is A => a.type === creator.type);
}

Observable.prototype.ofType = ofType;

declare module "rxjs/Observable" {
  interface Observable<T> {
    ofType: typeof ofType;
  }
}

You'd use the operator with the action creator like this, and it will work with plain JSON actions:

const actions = Observable
  .of<Action>({ type: "ADD", amount: 42 }, { type: "OTHER", amount: "forty-two" })
  .ofType(AddAction)
  .subscribe(a => console.log(a.amount)); // 42

Recently, I, too, got tired of typing so much boilerplate, so I wrote a library that builds on the mechanism in this answer: ts-action. I've also written an article that explains the two narrowing mechanisms in relation to Redux actions: How to Reduce Action Boilerplate. And, like you, I wanted to be able to use the mechansim with redux-observable, too; ts-action and its companion ts-action-operators will do that.

How about method like this? You can make it extension method. I use redux-typescript-actions to generate actionCreators which has changed the name to typescript-fsa now

import { combineEpics, ActionsObservable } from 'redux-observable';
import { Observable } from 'rxjs/Observable';
import { isType, Action, ActionCreator } from 'redux-typescript-actions';

export function ofType<T>(action$: ActionsObservable<Action<T>>, actionCreator: ActionCreator<T>): Observable<Action<T>> {
    return action$.filter((action: ActionCreator<T>) => isType(action, actionCreator));
}

Usage:

export const todoEpic = action$ => ofType(action$, actions.loadTodoStarted)

If you don't want to use typescript-fsa then you should write method using is operator obviously changing Action and ActionCreator to your types.

  export function isType<P>(action: ReduxAction, actionCreator: ActionCreator<P>): action is Action<P>{
             return action.type === actionCreator.type;
}
Related