question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

catchError with `throw` degrades type inference to empty type, causing unsafe code

See original GitHub issue

RxJS version: 6

Code to reproduce:

Consider this code that tries to convert an error thrown by an Observable into its own error class:

class SomeClass {
  private x: number;
  someClassField: string;
}
class UnrelatedClass {
  private y: number;
  unrelatedField: string;
}

declare const obs: Observable<SomeClass>;
// Working as expected.
obs.pipe(map(v => v.someClassField));
// Broken!
obs.pipe(
    catchError(e => {
      throw new Error(e);
    }),
    // Ouch, v cannot be of type UnrelatedClass here, should be SomeClass!
    map((v: UnrelatedClass) => 1));

The problem here is in the signature of catchError. Because the error handling function given doesn’t return any value, its return type is inferred as never. catchError is defined as:

function catchError<T, R>(
    selector: (err: any, caught: Observable<T>) =>
        Observable<R>): OperatorFunction<T, T|R>;

So if you pass in a selector that’s (e: any) => never, you end up with R being inferred as {} (the empty type), because there is no return. That in turn leads to the pipe chain degrading to {}. And now because in TypeScript callbacks are bivariant, that means users can pass any arbitrary type in place of the v passed down, e.g. UnrelatedClass above.

Expected behavior:

Should give a compile error that v is not of type UnrelatedClass.

Actual behavior:

Code is silently accepted, generic types are a lie, fails at runtime and is super confusing for newer users.

Issue Analytics

  • State:closed
  • Created 6 years ago
  • Comments:7 (6 by maintainers)

github_iconTop GitHub Comments

2reactions
mprobstcommented, Mar 7, 2018

I was going to suggest adding an overload to catchError like so:

function catchError<T>(
    selector: (err: any, caught: Observable<T>) =>
        never): OperatorFunction<T, T>;

That’ll make sure that if selector never returns a value, we don’t end up with an Observable<{} | T> as the result, but just an Observable<T>. That’s an improvement because it fixes the type inference decision here, so that we know the result above is an Observable<SomeClass>.

However testing this out, it turns out there’s another more fundamental problem: because TypeScript considers callback functions bivariant, we can specify any type we want in the map function, and TS will accept it silent. That’s independent of catchError:

declare const obs2: Observable<SomeClass>;
obs.pipe(
  map(v => v.someClassField),
  // v must be string here, but bivariance ruins the day :-(
  map((v: UnrelatedClass) => v.unrelatedField));

I’ve created a playground for this here, to make it a bit easier to throw around ideas.

1reaction
felixfbeckercommented, May 4, 2018

@benlesh a function that never returns and throws in all cases is correctly inferred as return type never.

What speaks against overloading catchError to return Observable<never> if the catcher returns never?

Read more comments on GitHub >

github_iconTop Results From Across the Web

pipe(catchError()) type issues. #3673 - ReactiveX/rxjs - GitHub
It compiles but Observable.throw should be replaced by throwError. By replacing, Typescript is forcing me to return the Observable<{} | Account> ...
Read more >
Why handle errors with catchError and not in the subscribe ...
Having a Service function return a clean Observable that only returns data (or null ) and isn't expected to throw any errors keeps...
Read more >
Julia Language Documentation - Read the Docs
Partly because of run-time type inference (augmented by optional type annotations), ... Exception Handling: try-catch, error and throw.
Read more >
Julia Language Documentation - Read the Docs
Existing code then seamlessly applies to the new data types. Partly because of run-time type inference (augmented by optional type annotations), ...
Read more >
Ozma: Extending Scala with Oz Concurrency
The sixth chapter discusses the implementation of Ozma: compiler, code runner and ... Scala makes extensive use of local type-inference in order to...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found