Observable<T> 内部的缩小类型

Narrowing type inside Observable<T>

简短说明

我希望能够写 actions$.ofType<MyAction>().map(a => ...) 而不是 actions$.filter(a => a.type === ActionType.MY_ACTION).map((a: MyAction) => ...)

背景

在 Angular 中使用 Redux 时,我试图通过遵循本文中列出的模式来保存一些样板文件:https://spin.atomicobject.com/2017/07/24/redux-action-pattern-typescript/。 TL;DR 是您可以在操作的类型字段中使用字符串枚举,以在不牺牲类型安全性的情况下简化 reducer 代码。它涉及制作如下所示的操作界面:

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;

在 reducer 中,您现在可以编写如下代码:

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

问题

我想在使用 redux-observable 编写 "epics" 时应用类似的模式。我想要一个函数 ofType<T>(),它同时按操作对象上的类型值进行过滤并缩小到正确的派生操作类型。请注意,redux-observable 已经有一个非通用的 ofType(),它将类型值作为参数,但是:1) 我宁愿将 Action 接口类型作为通用参数而不是类型值传递,以及 2)调用内置 ofType() 后的动作类型仍然只是 Action.

这就是我想要的代码:

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"));
}

可是,我想不出怎么写ofType<T>()。我一直在尝试不同的 TypeScript 功能,如索引类型和 keyof,但我无法让它工作。可能吗?

我希望函数是 ofType<T>(),但如果它必须是 ofType(ActionType.INCREMENT) 那么也没关系,只要它自动缩小操作类型而不必同时指定通用类型和类型键。

这样的方法怎么样?你可以使它成为扩展方法。我使用 redux-typescript-actions 生成 actionCreators,现在已将名称更改为 typescript-fsa

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));
}

用法:

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

如果您不想使用 typescript-fsa,那么您应该使用 is 运算符编写方法,显然将 Action 和 ActionCreator 更改为您的类型。

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

如果您愿意将 Actions 调整为 classes,那么您可以利用缩小 class 类型和 a definition of filter 的优势类型保护。另请注意,这会忽略使用 ActionTypes 枚举进行模式匹配的模式。

最重要的是,filter 中的箭头函数在没有显式类型保护的情况下不会变窄,即使它包装了类型保护,所以工厂方法让我们的生活更轻松:

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

将您的 Action 设置为具有空构造函数的 class,例如:

export class AddAction implements Action { // ...

那么用法就如你所愿:

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

不幸的是,您想要的一种模式匹配方法不起作用

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 :/

不幸的是,TypeScript 还没有那么聪明。

如果你使用 classes 作为动作创作者,你可以做你想做的事。

动作将创建为 class 个实例,但该机制不依赖于它们保持为 class 个实例并且不使用 instanceof。序列化的动作,发送到 Redux DevTools,然后使用时间旅行调试重播,仍然可以使用该机制。 类 仅用于有地方存储该类型的静态副本。

无论如何,这就是它的样子:

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

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

请注意,静态和实例 type 属性都是 readonly。它们需要这样才能被推断为字符串文字类型 - 您的缩减器代码表明您正在使用可区分的联合来缩小操作范围。

对于ofType运算符,需要使用类型保护。因为 class 包含静态 type,您可以像这样实现运算符:

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;
  }
}

您可以像这样将运算符与动作创建器一起使用,它将与普通 JSON 动作一起使用:

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

最近,我也厌倦了输入这么多样板文件,所以我写了一个基于这个答案中的机制的库:ts-action. I've also written an article that explains the two narrowing mechanisms in relation to Redux actions: How to Reduce Action Boilerplate。而且,和你一样,我也希望能够将 mechansim 与 redux-observable 结合使用; ts-action 及其同伴 ts-action-operators 会这样做。