Search code examples
typescriptrxjstypescript-typingsrxjs-pipeable-operatorscustom-operator

Properly type-guarding custom RxJS pipeable operators


I am working with an API that has a fixed, consistent structure of responses: it is always an object that has a data property on it. As it is very tiresome and too explicit to constantly map the data in RxJS requests (or ngrx effects), I decided to introduce a custom RxJS operator that plucks the data and applies an optional callback.

But now some of my effects complain about the type information (like: property x doesn't exist on type {}), so I guess my effort to properly type-guard the operator's I/O isn't enough:

export function mapData<T, R>(callback?: (T) => R) {
  return (source: Observable<T>) => source.pipe(
    map(value => value['data'] as R), // isn't that an equivalent of `pluck<T>('data')` ?
    map(value => typeof callback === 'function' ? callback(value) : value as R),
  );
}

An example of ngrx effect with type-guard problems :

switchMap(() => this.api.getData().pipe(
  mapData(),
  mergeMap(data => [
     new actions.DataSuccessAction({ id: data.id }), // <-- id does not exist on type {}
     new actions.SomeOtherAction(data),
  ]),
  catchError(err => of(new actions.DataFailureAction(err))),
)),

Which of course goes away when I type-cast it explicitly:

mapData<any, IMyData>(....),

I would love to hear whether this is the right, TypeScript way to do stuff.


Solution

  • You can use multiple overloads to model different type behaviors. I am not 100% sure what the behavior should be, it's not 100% clear from your question, but my reading of it suggests the following rules:

    1. If T has data and no callback is specified return data
    2. If we specify callback then the return is dictated by callback
    3. If no callback is specified and 1 does not apply just return T

    An overloaded version would look something like this:

    export function mapData<T, R>(callback: (data: T) => R) : OperatorFunction<T, R>
    export function mapData<T extends { data: any }>() : OperatorFunction<T, T['data']>
    export function mapData<T>() : OperatorFunction<T, T>
    export function mapData<T extends { data? : undefined } | { data: R }, R>(callback?: (data: T) => R) {
      return (source: Observable<T>) => source.pipe(
        map(value => typeof callback === 'function' ? callback(value) : (value.data ? value.data : value)),
      );
    }
    
    // Tests
    of({ data: { id: 0 }}).pipe(
      mapData(),
      mergeMap(data => [
        new actions.DataSuccessAction({ id: data.id }), // <-- id does not exist on type {}
        new actions.SomeOtherAction(data),
      ]),
      catchError(err => of(new actions.DataFailureAction(err))),
    )
    
    of({ other: { id: 0 }}).pipe(
      mapData(d =>d.other),
      mergeMap(data => [
        new actions.DataSuccessAction({ id: data.id }), // <-- id does not exist on type {}
        new actions.SomeOtherAction(data),
      ]),
      catchError(err => of(new actions.DataFailureAction(err))),
    )
    
    of({ data: { id: 0 }}).pipe(
      mapData(d =>d.data),
      mergeMap(data => [
        new actions.DataSuccessAction({ id: data.id }), // <-- id does not exist on type {}
        new actions.SomeOtherAction(data),
      ]),
      catchError(err => of(new actions.DataFailureAction(err))),
    )
    
    
    
    // Filler classes
    namespace actions {
      export class DataSuccessAction<T>{
        constructor(public data:T){}
      }
      export class SomeOtherAction<T>{
        constructor(public data:T){}
      }
    
      export class DataFailureAction<T>{
        constructor(public data:T){}
      }
    }