redux-observable / redux-observable

RxJS middleware for action side effects in Redux using "Epics"
https://redux-observable.js.org
MIT License
7.85k stars 466 forks source link

Improve `ofType` type declaration for TS >3.3 #622

Open tjfryan opened 5 years ago

tjfryan commented 5 years ago

What is the current behavior?

When using redux-observable in TypeScript, providing a key or keys to ActionsObservable.prototype.ofType doesn't refine the type of the actions emitted and often requires a redundant action.type check in order to maintain type correctness.

What is the expected behavior?

With TypeScript 3.3, it is possible for the type system to automatically refine the Action union to only the options that match the provided keys. Here's a rough demo

I'm currently using the following custom typedef as a stop-gap in my codebase.

type ActionByType<Union, Type> = Union extends {type: Type} ? Union : never;

declare module 'redux-observable' {
  interface ActionsObservable<T extends Action> {
    ofType<A extends Array<Nullable<T['type']>>>(
      ...keys: A
    ): ActionsObservable<ActionByType<T, A[number]>>;
  }
}
thorn0 commented 5 years ago

The pipeable version (works only with TS 3.4):

declare module 'redux-observable' {
  export function ofType<T extends Action, A extends Array<Nullable<T['type']>>>(
    ...key: A
  ): (source: Observable<T>) => Observable<ActionByType<T, A[number]>>;
}
jayphelps commented 5 years ago

I think 3.3 was released in January. My only issue is whether or not people have already upgraded or are willing to do so at large. I don't think there's a way to support older versions at the same time?

thorn0 commented 5 years ago

You can probably add a postinstall script that would patch the type definitions depending on the found version of TS. 🤣

thorn0 commented 5 years ago

The pipeable version stopped working with TS 3.5.1. Fixed it by replacing Action with Action<string>:

declare module "redux-observable" {
  type ActionByType<Union, Type> = Union extends { type: Type } ? Union : never;

  export function ofType<
    TAction extends Action<string>,
    TActionTypes extends Array<TAction["type"]>
  >(
    ...key: TActionTypes
  ): (
    source: Observable<TAction>
  ) => Observable<ActionByType<TAction, TActionTypes[number]>>;
}

Another way to define it is:

declare module 'redux-observable' {
  type ActionByType<Union, Type> = Union extends { type: Type } ? Union : never;

  export function ofType<
    TActionTypes extends string[]
  >(
    ...key: TActionTypes
  ): <TAction extends Action<string>>(
    source: Observable<TAction>
  ) => Observable<ActionByType<TAction, TActionTypes[number]>>;
}

Defined this way, it works correctly even when invoked without pipe (e.g. ofType("bar")(actions$)).

UPD: the first way is better after all, the autocomplete is more helpful: image

donifer commented 5 years ago

Would love this to see the light 😍

leoyli commented 5 years ago

I was wondering for this a while, nice job @thorn0!!!

thynson commented 3 years ago

I found an optimized way to declare ofType that could narrow the type of output based on the param.

function myOfType<
  Input extends AnyAction,
   // Note: Without letting `Type` extending string, Type cannot be inferred to a literal type.
  Type extends Input['type'] & string,
  Output extends Input = Extract<Input, Action<Type>>,
>(...types: Type[]): OperatorFunction<Input, Output> {
  return filter((input): input is Output => {
     eturn types.indexOf(input.type) >= 0;
  });
}

And then the filtered action type can be narrowed.

  type InputAction = {type: 'a'; foo: number} | {type: 'b'; bar: string} | {type: 'c'; foo: number};

  const observable = of<InputAction[]>({type: 'a', foo: 1}, {type: 'b', bar: 'bar'}, {type: 'a', foo: 2});

  observable.pipe(
    myOfType('a'),
    tap((value) => console.log(value.foo)), // okay
    tap((value) => console.log(value.bar)), // compile failure
  );
  // compared to the builtin `ofType`, unless type param of `ofType` is explicit specified, 
  // the type of output is same with input
  observable.pipe(
    ofType('a'),
    tap((value) => console.log(value.foo)), // compile failure, foo is not a member of InputAction
  );

Unfortunately, I found that the action type auto-complete seems to be impossible with my own ofType when the output type is narrowed, after trying many tricky ways. However, I think it's still worth do it this way, as the output action type is inferred.