typescriptauthorizationobservabletypecheckingtypeguards

RxJS filter function not narrowing type unless directly given a typeguard its the only parameter


I have been working on an auth service that uses an rxjs behavior subject to store the last retrieved auth object, and triggers a re-fetch if it has expired (or has not been fetched at all yet).

My question is about the TypeScript type checker. I have written the typeguard isNotUndefined that asserts - well, exactly what you would expect.

export function isNotUndefined<T>(input: T | undefined): input is T {
  return input !== undefined;
}

I've already had to write the above typeguard rather than being able to rely on auth !== undefined. I now can't for the life of me understand why, in the pipe in authGetter$ in the code below, the type of the value in the pipe is not reduced down to just Auth after the first filter. Instead, the type is still Auth | undefined, and it requires the second filter with just the type guard to get the type narrowed down to just Auth.

So in summary, why do I need the second filter to narrow the type to just Auth? In addition, because I am coding on my own with nobody reviewing it, I would greatly appreciate anyone pointing out 'code smells' they recognise (with suggestions on what do to instead).

export default class AuthService {
  private static lastAuth$ = new BehaviorSubject<Auth | undefined>(undefined);

  private static authGetter$ = AuthService.lastAuth$.pipe(
    filter(auth => {
      if (isNotUndefined(auth) && auth.expiry > new Date()) {
        return true ; // identical resulting type with "return isNotUndefined(auth);"
      } else {
        // retry if auth doesn't exist or is expired
        AuthService.authorise().then(newAuth =>
          AuthService.lastAuth$.next(newAuth)
        );
        return false;
      }
    }),
    tap(v => {}), // typechecker says "(parameter) v: Auth | undefined"
    filter(isNotUndefined),
    tap(v => {}) // typechecker says "(parameter) v: Auth"
  );

  static getAuth$(): Observable<Auth> {
    return this.authGetter$.pipe(first());
  }

  private static async authorise(): Promise<Auth> {
    // auth code goes here (irrelevant for this question)...
    // typecast dummy return to make typechecker happy
    return Promise.resolve(<Auth>{});
  }
}

I attach a photo of my code in nice syntax highlighting for your viewing ease and pleasure :)

my code in nice syntax highlighting


Solution

  • Update for TypeScript 5.5:

    TypeScript now infers type predicate return types from function bodies, so now auth !== undefined in the body of your function should indeed be viewed as a type guard. That is, type guard functions will now propagate:

    const doesPropagate = <T>(x: T | undefined) => isNotUndefined(x);
    // const doesPropagate: <T>(x: T | undefined) => x is T
    

    So you'll get the desired filter behavior if you write a valid type guard body:

    const b = o.pipe(filter(x => isNotUndefined(x))); // Observable<string>
    

    Unsafe type guard bodies will not narrow, though, which is good. You don't want (x: string | undefined) => isNotUndefined(x) && x.length > 3 to be inferred as a type guard, since when it returns false you cannot assume that x is undefined, which is what a type guard function would do. This isn't an issue for filter() which only looks at the true case, but it's still not something you want TS to infer in general. So here you'd still need to annotate the return type:

    const d = o.pipe(filter((x): x is string => isNotUndefined(x) && x.length > 3)); 
    // Observable<string>;
    

    It's still a better idea to do multiple filters if you want this behavior, though:

    const e = o.pipe(filter(isNotUndefined), filter(x => x.length > 3)); // Observable<string>;
    

    Before TypeScript 5.5:

    User-defined type guard functions are, at least currently, strictly user-defined. They are not inferred automatically by the compiler. If you want a boolean-returning function to behave as a type guard with a type predicate return type, you need to annotate explicitly it as such:

    const doesNotPropagate = <T>(x: T | undefined) => isNotUndefined(x);
    // const doesNotPropagate: <T>(x: T | undefined) => boolean
    

    The function doesNotPropagate() behaves the same as isNotUndefined() at runtime, but the compiler does not see it as a type guard anymore, so if you use it as a filter you won't eliminate undefined in the compiler.

    There are multiple issues in GitHub about this; the currently open issue tracking propagating/flowing type guard signatures is microsoft/TypeScript#16069 (or possibly microsoft/TypeScript#10734). But it doesn't look like there's much movement here, so for the time being we will need to just deal with the language the way it is.


    Here's a toy example to play with to explore the different possible ways of dealing with this, since the example code in your question doesn't constitute a minimal reproducible example suitable for dropping into a standalone IDE . You should be able to adapt these to your own code.

    Let's say we have a value o of type Observable<string | undefined>. Then this works:

    const a = o.pipe(filter(isNotUndefined)); // Observable<string>
    

    but this doesn't because of the reason listed above... type guard signatures don't propagate:

    const b = o.pipe(filter(x => isNotUndefined(x))); // Observable<string | undefined>
    

    We can regain the type guard signature and behavior if we manually annotate the arrow function like this:

    const c = o.pipe(filter((x): x is string => isNotUndefined(x))); // Observable<string>;
    

    From this you could do extra filtering logic if you want:

    const d = o.pipe(filter((x): x is string => isNotUndefined(x) && x.length > 3)); 
    // Observable<string>;
    

    Here the filter checks that the string is defined and that its length is greater than 3.

    Note that this technically is not a well-behaved user-defined type guard, since they tend to treat false results as meaning that the input is narrowed to exclude the guarded type:

    function badGuard(x: string | undefined): x is string {
      return x !== undefined && x.length > 3;
    }
    const x = Math.random() < 0.5 ? "a" : undefined;
    if (!badGuard(x)) {
      x; // narrowed to undefined, but could well be string here, oops
    }
    

    Here, if badGuard(x) returns true, you know that x is string. But if badGuard(x) returns false, you don't know that x is undefined... but that's what the compiler thinks.

    It's true that in your code you are not really dealing with the situation where the filter returns false (I guess the subsequent pipe parameters just don't fire?), so you don't really have to worry too much about this. Still, it might be better to refactor the code into one correct type guard followed by the non-type-guard filter that does the extra logic:

    const e = o.pipe(filter(isNotUndefined), filter(x => x.length > 3)); // Observable<string>;
    

    This should amount to the same result at runtime, but here the first filter correctly narrows from Observable<string | undefined> to Observable<string>, and the second filter keeps the Observable<string> (and the x in the callback is a string) and does the extra logic that filters on length.

    And this has the extra benefit of not requiring a type annotation, since you're not trying to propagate the type guard signature anywhere. So this would probably be the method I'd recommend.


    Stackblitz link to code