3

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.

Magnus Grindal Bakken
  • 2,083
  • 1
  • 16
  • 22

3 Answers3

3

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.

concat
  • 3,107
  • 16
  • 30
  • Using classes would indeed make life easier here, but from what I've read some consider it bad practice to use action classes in Redux. For instance, according to this thread https://github.com/reactjs/redux/issues/2361 I would have to add some logic to convert the class instances to plain JS objects prior to dispatching. I could do that, but I kind of like the simplicity of everything just being plain objects in the first place. – Magnus Grindal Bakken Sep 20 '17 at 08:25
2

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.

cartant
  • 57,105
  • 17
  • 163
  • 197
  • I've finally had time to look at this again, and this answer is definitely my favorite. I'd actually already rewritten my actions to be classes for other reasons, but your libraries make the process a lot smoother. – Magnus Grindal Bakken Nov 24 '17 at 15:01
1

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;
}
MistyK
  • 6,055
  • 2
  • 42
  • 76
  • This is a pretty good solution. My action creators are currently just functions and I'm not using the FSA pattern because I don't really like how everything has to be inside a "payload", but I'll have to see if I can do something similar to this without using the FSA object structure. – Magnus Grindal Bakken Sep 20 '17 at 08:45
  • If you don't like payload property you can do similar sort of thing and instead of having payload :T you can have something like this: type ActionCreator = { [P in keyof T]: T[P]} – MistyK Sep 20 '17 at 12:36