ReactiveX / ReactiveX/rxjs

Feature request for `first` et al: Configurable empty errors.

Open
#7,330 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
31.7k
Forks
3k
PR merge metrics
No merged PRs in 30d

Description

It's come up more than once in recent days where I've run across code or confused developers that didn't know where an EmptyError was originating from.

Basically one of the problems is this:

source$.pipe(
  /* ... operators ... */
  first(somePredicate),
  /* ... operators ... */
  first(somePredicate),
  /* ... operators ... */
  catchError(err => {
    if (err instanceof EmptyError) {
      // who did this?
    }
  })
)
Current Workarounds

You can always leverage the defaultValue argument, but if you want to use the error channel, you'd have to do something like this catching and rethrowing right after:

source$.pipe(
  /* ... operators ... */
  first(somePredicate),
  catchError(err => {
    if (err instanceof EmptyError) {
      throw new CustomError1();
    }
    throw err;
  }),
  /* ... operators ... */
  first(somePredicate),
  catchError(err => {
    if (err instanceof EmptyError) {
      throw new CustomError2();
    }
    throw err;
  })
  /* ... operators ... */
)

This can of course be made into something reusable something like so:

export function firstOtherwise<T, O>(
  {
    find,
    otherwise
  } : {
    find: (value: T, index: number) => boolean,
    otherwise: () => O
  }
): OperatorFunction<T, T> {
  return pipe(
    first(find),
    catchError(err => {
      if (error instanceof EmptyError) {
        return of(otherwise())
      }
      throw err;
    })
  )
}

Perhaps a better workaround for some people is filter, take and throwIfEmpty:

source$.pipe(
  filter(predicate),
  take(1),
  throwIfEmpty(() => new CustomError1()),
)
Proposed Solution 1: Configuration

Neither of the above are great. Sadly, the best solution to this will require a breaking change we can't really deprecate cleanly (but we can deprecate in a dirty way):

source$.pipe(
  /* ... operators ... */
  first(somePredicate, { emptyError: () => new CustomError1() }),
  /* ... operators ... */
  first(somePredicate, { emptyError: () => new CustomError2() }),
  /* ... operators ... */
)

The problem with this solution is the second argument is currently the defaultValue, has a similar use case. We could examine the default value like typeof defaultValue === 'object' && 'emptyError' in defaultValue and it would probably be fine in 99.999999% of cases, but it's not bulletproof. That object would also have to support a defaultValue property, so we'd need to check for that too. For at least a whole major release.

Proposed Solution 2: Function

In this one we would examine the second argument, and if it's a function, we'd call it to get the default value. If it throws it emits the error (obviously).

source$.pipe(
  /* ... operators ... */
  first(somePredicate, () => 'Some default value'), // default value
  /* ... operators ... */
  first(somePredicate, () => { throw new CustomError2() }), // custom empty error
  /* ... operators ... */
)

The problem here is I think collisions with existing code and regressions are more likely than the other one. It's also not as readable.

Proposed Solution 3: Combination of the above

In this one we have a configuration/options object with a defaultValue, valueNotFound, onEmpty, or getDefault (bike sheddable) property on it that would have a function that works as solution 2 above does.

source$.pipe(
  /* ... operators ... */
  first(somePredicate, {
    whenValueNotFound: () => 'Some default value'
  }), // default value
  /* ... operators ... */
  first(somePredicate, {
    valueNotFound: () => { throw new CustomError2() }
  }), // custom empty error
  /* ... operators ... */
)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading the first operator's existing defaultValue and EmptyError behavior, then compare it with throwIfEmpty. The issue presents three incompatible API proposals without selecting one; completion requires an agreed backward-compatible design and tests covering defaults, custom errors, and existing behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.