Skip to the code
cf.

Observable with map and filter

Write a class Observable, the core of RxJS. new Observable(producer) stores producer, a function that receives a subscriber object with next(value), error(err), and complete() methods. The producer may return a teardown function that cleans up.

Nothing runs until someone subscribes, and every subscribe runs the producer again.

subscribe(observer) takes an object with optional next, error, and complete methods, or a single function, used as next, or nothing at all. It returns a subscription, { unsubscribe }.

The subscriber passes notifications on to the observer until the subscription closes, which happens on error, complete, or unsubscribe(). After that nothing more gets through. If the producer throws, the error goes to the observer's error.

The teardown runs exactly once, when the subscription closes. If the producer completes before it has returned the teardown, the teardown still runs, by the time subscribe returns. Calling unsubscribe() a second time doesn't run it again.

map(fn) returns a new Observable with each value replaced by fn(value). filter(predicate) returns a new Observable with only the values for which predicate(value) is truthy.

Both pass errors and completion through, and an error thrown by fn or predicate goes to error. Unsubscribing from the Observable they return unsubscribes from the source.

const ticks = new Observable((subscriber) => {
  let n = 0;
  const id = setInterval(() => subscriber.next(n++), 1000);
  return () => clearInterval(id);
});
 
const subscription = ticks
  .filter((n) => n % 2 === 0)
  .map((n) => n * 10)
  .subscribe((value) => console.log(value)); // 0, 20, 40, …
 
subscription.unsubscribe(); // clears the interval

What the tests check

  1. is lazy: the producer runs on each subscribe, not before
    const producer = spy();
    const source = new Observable(producer);
    expect(producer).not.toHaveBeenCalled();
    source.subscribe(() => {});
    source.subscribe(() => {});
    expect(producer).toHaveBeenCalledTimes(2);
    
  2. delivers values, then complete, to an observer object
    const source = new Observable((subscriber) => {
      subscriber.next(1);
      subscriber.next(2);
      subscriber.complete();
    });
    const log = [];
    source.subscribe({ next: (value) => log.push(value), complete: () => log.push("done") });
    expect(log).toEqual([1, 2, "done"]);
    
  3. accepts a function as next, a partial observer, or none
    const source = new Observable((subscriber) => {
      subscriber.next("a");
      subscriber.complete();
    });
    const next = spy();
    source.subscribe(next);
    expect(next).toHaveBeenCalledWith("a");
    expect(() => source.subscribe()).not.toThrow();
    expect(() => source.subscribe({})).not.toThrow();
    const subscription = source.subscribe({ complete() {} });
    expect(typeof subscription.unsubscribe).toBe("function");
    
  4. nothing gets through after complete
    const source = new Observable((subscriber) => {
      subscriber.next(1);
      subscriber.complete();
      subscriber.next(2);
      subscriber.error(new Error("late"));
      subscriber.complete();
    });
    const next = spy();
    const error = spy();
    const complete = spy();
    source.subscribe({ next, error, complete });
    expect(next).toHaveBeenCalledTimes(1);
    expect(error).not.toHaveBeenCalled();
    expect(complete).toHaveBeenCalledTimes(1);
    
  5. unsubscribe stops the values and runs the teardown once
    const clock = useFakeTimers();
    const teardown = spy();
    const ticks = new Observable((subscriber) => {
      let n = 0;
      const id = setInterval(() => subscriber.next(n++), 100);
      return () => {
        clearInterval(id);
        teardown();
      };
    });
    const next = spy();
    const subscription = ticks.subscribe(next);
    clock.tick(250);
    subscription.unsubscribe();
    subscription.unsubscribe();
    clock.tick(500);
    expect(next.calls.map((call) => call.args[0])).toEqual([0, 1]);
    expect(teardown).toHaveBeenCalledTimes(1);
    expect(clock.pending()).toBe(0);
    
  6. runs the teardown when the producer completes before returning it
    const teardown = spy();
    const source = new Observable((subscriber) => {
      subscriber.next(1);
      subscriber.complete();
      return teardown;
    });
    const subscription = source.subscribe(() => {});
    expect(teardown).toHaveBeenCalledTimes(1);
    subscription.unsubscribe();
    expect(teardown).toHaveBeenCalledTimes(1);
    
  7. errors thrown by the producer or by fn go to error
    const clock = useFakeTimers();
    const error = spy();
    new Observable(() => {
      throw new TypeError("producer");
    }).subscribe({ error });
    const ticks = new Observable((subscriber) => {
      let n = 0;
      const id = setInterval(() => subscriber.next(n++), 100);
      return () => clearInterval(id);
    });
    const next = spy();
    ticks
      .map((n) => {
        if (n === 1) throw new RangeError("fn");
        return n;
      })
      .subscribe({ next, error });
    clock.tick(300);
    expect(next.calls.map((call) => call.args[0])).toEqual([0]);
    expect(error).toHaveBeenCalledTimes(2);
    expect(error.calls[0].args[0]).toBeInstanceOf(TypeError);
    expect(error.calls[1].args[0]).toBeInstanceOf(RangeError);
    expect(clock.pending()).toBe(0);
    
  8. map and filter return new Observables of the changed values
    const source = new Observable((subscriber) => {
      [1, 2, 3, 4, 5].forEach((n) => subscriber.next(n));
      subscriber.complete();
    });
    const result = source.filter((n) => n % 2 === 1).map((n) => n * 10);
    expect(result).toBeInstanceOf(Observable);
    expect(result).not.toBe(source);
    const log = [];
    result.subscribe({ next: (value) => log.push(value), complete: () => log.push("done") });
    expect(log).toEqual([10, 30, 50, "done"]);
    
  9. unsubscribing from a chain tears down the source
    const clock = useFakeTimers();
    const teardown = spy();
    const ticks = new Observable((subscriber) => {
      let n = 0;
      const id = setInterval(() => subscriber.next(n++), 100);
      return () => {
        clearInterval(id);
        teardown();
      };
    });
    const next = spy();
    const subscription = ticks.map((n) => n * 2).filter((n) => n > 0).subscribe(next);
    clock.tick(300);
    subscription.unsubscribe();
    clock.tick(300);
    expect(next.calls.map((call) => call.args[0])).toEqual([2, 4]);
    expect(teardown).toHaveBeenCalledTimes(1);
    expect(clock.pending()).toBe(0);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).