#Type inference in TypeScript with generic

22 messages · Page 1 of 1 (latest)

grave reef
#

I would like to abstract some error management in my application.
The goal is to prepare an object with enough information so the view can adapt its behavior easily.

  sandbox() {
    // Simulation of a backend query
    const stream$ = of([1,2,3]).pipe(
      catchError((error) => of(new MyError(error))),
      takeUntil(this.destroyed$)
    );

    this.test$ = this.handleState(stream$);
  }

  handleState<T>(stream$: Observable<T | MyError>): Observable<UiDataState<T>> {
    const errorStream$ = stream$.pipe(
      filter((data) => data instanceof MyError),
      map(() => null),
    );
    const noErrorStream$ = stream$.pipe(
      startWith(null),
      filter((data) => !(data instanceof MyError)),
      map((data) => data as T),
    );

    const isError$ = errorStream$.pipe(
      startWith(false),
      map(() => true)
    );

    return combineLatest([
      isError$,
      noErrorStream$,
    ]).pipe(
      map(([isError, data]) => {
        return {
          isError: isError,
          data: data
        } as UiDataState<T>
      })
    );
  }

This is working but I would like to be able to let the programmer decides the type of error (cf. MyError) to use.
Is there a solution with generics? I tried many solutions but the one above is working.
My conclusion is that it's not possible but I wanted to confirm this with more experienced guys that me 🙂

jade wren
#

@grave reef TypeScript magic for you 🪄

handleState<ValueT, ErrorCtorT extends {new(...args: any[]): any}>(stream$: Observable<ValueT | InstanceType<ErrorCtorT>>, errorCtor: ErrorCtorT): Observable<UiDataState<ValueT>>;

This should allow you to do something like:

service.handleState(state$, MyError);

assuming you're okay with error types always being classes.

grave reef
#

Thx for your answer. I'll investigate on this one.

jade wren
#

Hmmm, something's being a little weird there with the inference

grave reef
#

But in theory, an error could be a primitive type (ex: catchError((error) => of('error')))

jade wren
#

Yeah.

#

In that case, you'd probably want something like this:

function handleState<ValueT, ErrorT>(obs$: Observable<ValueT | ErrorT>, isError: (value: ValueT | ErrorT) => value is ErrorT): Observable<UiDataState<Exclude<ValueT, ErrorT>>>
#

That lets you pass a type guard function which can be used to distinguish values and errors:

// errors are strings, values are numbers
const state$: Observable<number|string> = getData();

function isStringError(value: number|string): value is string {
  return typeof value === 'string';
}

// returns Observable<UiDataState<number>> :)
const res$ = service.handleState(state$, isStringError);
#

So internally, you would replace data instanceof MyError with isError(data)

grave reef
#

Thank you for your answer that is beyond my expectations. I have a confcall now but I'll try that later this morning!

jade wren
#

👍 enjoy!

grave reef
#

Ok so I tried different things and it's almost working (in some cases) but I have some (stupid) questions:

  • Why the return type of isStringError is value is string? Shouldn't it be a boolean?
#
  • The return type of handleState is Observable<UiDataLoader<Exclude<ValueT, ErrorT>>> but the compiler is complaining but I it's working with Observable<UiDataLoader<ValueT>>
grave reef
#

Here is the full implementation

  handleState<ValueT, ErrorT>(
    stream$: Observable<ValueT | ErrorT>,
    isError: (value: ValueT | ErrorT) => value is ErrorT,
    trigger$?: Observable<any>
  ): Observable<UiDataLoader<ValueT>> {

    const t$ = trigger$ != null ? trigger$ : of(null);

    const errorStream$ = stream$.pipe(
      filter((data) => isError(data)),
      map(() => null),
    );
    const noErrorStream$ = stream$.pipe(
      filter((data) => !(isError(data))),
      map((data) => data as ValueT),
    );

    const safeData$ = merge(
      errorStream$,
      noErrorStream$
    );
    const isLoading$ = merge(
      t$.pipe(map(() => true)),
      safeData$.pipe(map(() => false))
    );
    const isLoaded$ = safeData$.pipe(
      map(() => true)
    );
    const isError$ = merge(
      t$.pipe(map(() => false)),
      errorStream$.pipe(map(() => true))
    );

    return combineLatest([
      isLoading$.pipe(startWith(true)),
      isLoaded$.pipe(startWith(false)),
      isError$.pipe(startWith(false)),
      noErrorStream$.pipe(startWith(null)),
    ]).pipe(
      map(([isLoading, isLoaded, isError, data]) => {
        return {
          loading: isLoading,
          loaded: isLoaded,
          saving: false, // TODO
          error: isError,
          data: data
        };
      }),
      shareReplay()
    );
  }
#
  isStringError(value: any | string): value is string {
    return typeof value === 'string';
  }
  isMyError(value: any | MyError): value is MyError {
    return value instanceof MyError;
  }
#

So managing error with string or object

const stream$ = trigger$.pipe(
      switchMap((id) => this.documentRepositoryService.getIncomingDocumentById(id).pipe(
        catchError((error) => of('error')),
        //catchError((error) => of(new MyError(error))),
        takeUntil(this.destroyed$)
      )),
      shareReplay()
    );

    this.test$ = this.handleState(
      stream$,
      this.isStringError,
      //this.isMyError
      trigger$,
    );
#

Note the trigger$. The use case is to start the loading mechanism and handler all the repeating stuff I'm doing to almost all the remote queries.

#

I'm aware that this mimic what I usually do with NgRx. But I wanted an alternative solution in the case NgRx is not "available" (not used in the project).

#

Any though on advice on this code? (especially for the trigger)

jade wren
#

I don't understand the question I guess