Writing a custom ngrx operator and returning the source observable type

This, at least, compiles; I don't know if it also does what you need.

public effect3$: Observable<Action> = createEffect(() => {
  const a:Action[]= []

  return this.actions$.pipe(
    ofType(doSomething),
    this.someCustomOperatorReturningStaticTypes(),
    this.thisWontWork(a),
    tap(({aCustomProperty}) => {
      // The type is inferred
      console.log(aCustomProperty);
    }),
  )
});

private thisWontWork<A extends Action>(actionsToWaitFor$: Action[]): OperatorFunction<A, A> {
  return (source$) => {
    return source$.pipe(
      tap(() => {
        console.log('Should work')
      })
    )
  }
}

I wasn't able to run it in the StackBlitz, any hint?

Hope this helps


Came across this question and I thought I'd add a couple explanations in case you are still wondering and if anyone comes across this in future!

First - in your example you're overthinking it in terms of the return type.

If you want to infer something as the type it is passed as (e.g here you want your action to be returned as the type it is) you just return the same generic which you pass in - as the answer says like:

function foo<A>(val: A): A {
  return val;
}

If I call foo() with a number, I get a number back. If I call it with a specific action type, then the same thing happens. This is why you just need the return type OperatorFunction<A, A> - same thing in, same thing out.

Second - how does ofType work?

The key here is that you need some set of actions which you are narrowing.

Using createAction takes care of this for you, but for example's sake I'll create this by hand.

type FooAction = { type: 'FOO_ACTION' };
type BarAction = { type: 'BAR_ACTION' };

// This type gets built by you using createAction - this is your Action type
type MyActions = FooAction | BarAction;

Next we need a type which is going to take our set of actions (MyActions) - and narrow it to a specific action based on the type.

type NarrowActionType<T extends { type: any }, K extends string> = T extends { type: K }
  ? T
  : never;

We can verify this works:

type NarrowedActionTest = NarrowActionType<MyActions, 'FOO_ACTION'>;

Verifying NarrowedActionTest works in TS playground

ofType is a higher order function which first accepts a string, and then the observable stream to operate on. I'm not going to use observables to keep it simpler, but it's the same principle.

In essence we want something along the lines of (type: string) => (action: Action) => action is NarrowActionType for the type signature.

  • NarrowActionType needs the overall action type as it's first type parameter, which we will call A
  • NarrowActionType needs the string literal type which we are using to narrow with, which we will call T
  • T is going to be a string, so T extends string
  • We only know the type of T to begin with, and then the type of A once the object stream is applied, so let's use one generic parameter at each function application
  • The function is going to ultimately check if A.type is the same as type. So we should enforce this exists, with A extends { type: any }
function isOfType<T extends string>(type: T) {
    return <A extends { type: any }>(action: A): action is NarrowActionType<A, T> => {
        return action.type === type
    }
}

Now we can test this function on a MyActions type, and see if it can narrow the output:


const myAction: MyActions = { type: 'FOO_ACTION' };
const isFooAction = isOfType('FOO_ACTION')(myAction);

if (isOfType('FOO_ACTION')(myAction)) {
    type myNarrowedAction = typeof myAction; // is FooAction
}

Full playground link here: https://www.typescriptlang.org/play/#code/C4TwDgpgBAcghgJwQewO4EEDGwCWyB2AKuBADyFQQAewE+AJgM5QDeUokAXFHPiFAF8ANFADSlGnSZRGwBDnwBzAHxQAvFArVaDZmw4Ru4gQCgoUAPyazUbvggA3CAgDcJkwagAxZMiy4CdVZ2Em4Aci8AeUiAfXQAYUIASUiYMME3TwAhRH88fCD9UKgwrPQAJTjElLSM9xMAegbNAAscZk9FCGBmACMAVxwAG2AoXv4QZH6ofsYFRShMBAg4WjzAgFp2NuZ2qEn+hCh1goMPEigAWRAT5g0fP2x8qAAfKByEE7dzyFhEFFQEHoJ0IEFkQXgSDQIJIpGutxEEWiVWSqTCym+ADN+vgnoF2pFMcRIOQJDppLJ5EplAAKAzcQgASlYNnMy2AhwKpHQZKkehCXB4fEEtLgePw3HQjO4YoCBT2kIBMJJ6BEhFUalULFZ5l17M5PHFADpPGozQKIDrTKYTJgCOCALY3cXceHiu7BeklKKxBKo2oCNx2-Dg9oPE5BAlEkg0pG+6poxk0p0nRnfHCYqA0qPEiCxn0omphJMp8WM5na3UWqBOxVoIERjQGZCZ0tylxQJpQPbh8UmUxAA