Skip to the code
cf.

Lazy iterator helpers

Write lazy(iterable). It returns a sequence over any iterable, endless ones included: an object with the methods below.

map(fn) returns a new sequence of fn(value, index) for each value. index counts the values of the sequence map was called on: 0, 1, 2, and so on.

filter(fn) returns a new sequence of the values for which fn(value, index) is truthy, with index counted the same way.

take(n) returns a new sequence of the first n values. It never pulls more than n values, and when it stops early it closes the source's iterator (calls its return()), so a generator's finally blocks run.

toArray() runs the chain and returns an array of its values. A sequence is iterable too, so for...of and spread work on it.

Nothing runs until toArray() or a loop asks for values. Then values go through the chain one at a time: the first value goes through every step before the second is read.

Each method leaves the sequence it's called on unchanged. Running a sequence reads its source again, so a sequence over an array can be run any number of times.

Build the helpers yourself: don't use the built-in iterator helpers (Iterator.prototype.map and the rest), which do the same job.

function* naturals() {
  for (let n = 1; ; n++) yield n;
}
 
lazy(naturals())
  .map((n) => n * n)
  .filter((n) => n % 2 === 1)
  .take(3)
  .toArray(); // [1, 9, 25]
 
const labels = lazy(["a", "b", "c"]).map((letter, i) => letter + i);
labels.toArray(); // ["a0", "b1", "c2"]
[...labels];      // ["a0", "b1", "c2"]

What the tests check

  1. map, filter, take, and toArray chain together
    // Endless for lazy code; eager code gets an error instead of a frozen page.
    function* naturals() {
      for (let n = 1; n < 1000; n++) yield n;
      throw new Error("read 1000 values from an endless generator: something isn't lazy");
    }
    const odd = lazy(naturals())
      .map((n) => n * n)
      .filter((n) => n % 2 === 1)
      .take(3);
    expect(odd.toArray()).toEqual([1, 9, 25]);
    expect(lazy([1, 2, 3]).toArray()).toEqual([1, 2, 3]);
    
  2. does nothing until toArray() is called
    const double = spy((x) => x * 2);
    const doubled = lazy([1, 2, 3]).map(double).filter(() => true);
    expect(double).not.toHaveBeenCalled();
    expect(doubled.toArray()).toEqual([2, 4, 6]);
    expect(double).toHaveBeenCalledTimes(3);
    
  3. passes values through the chain one at a time
    const log = [];
    lazy([1, 2])
      .map((x) => {
        log.push("map " + x);
        return x * 10;
      })
      .filter((x) => {
        log.push("filter " + x);
        return true;
      })
      .toArray();
    expect(log).toEqual(["map 1", "filter 10", "map 2", "filter 20"]);
    
  4. take pulls no more than n values
    // Endless for lazy code; eager code gets an error instead of a frozen page.
    function* naturals() {
      for (let n = 1; n < 1000; n++) yield n;
      throw new Error("read 1000 values from an endless generator: something isn't lazy");
    }
    const seen = spy((x) => x);
    expect(lazy(naturals()).map(seen).take(3).toArray()).toEqual([1, 2, 3]);
    expect(seen).toHaveBeenCalledTimes(3);
    expect(lazy([1, 2]).take(0).toArray()).toEqual([]);
    expect(lazy([1, 2]).take(5).toArray()).toEqual([1, 2]);
    
  5. callbacks get each value's index in their own sequence
    expect(lazy(["a", "b", "c"]).map((x, i) => x + i).toArray()).toEqual(["a0", "b1", "c2"]);
    expect(lazy([5, 6, 7, 8]).filter((x, i) => i !== 1).toArray()).toEqual([5, 7, 8]);
    const indexes = lazy([10, 11, 12, 13])
      .filter((x) => x % 2 === 0)
      .map((x, i) => i);
    expect(indexes.toArray()).toEqual([0, 1]);
    
  6. a sequence works with spread and for...of
    const plusOne = lazy(new Set([1, 2, 3])).map((x) => x + 1);
    expect([...plusOne]).toEqual([2, 3, 4]);
    const seen = [];
    for (const value of plusOne) seen.push(value);
    expect(seen).toEqual([2, 3, 4]);
    
  7. each method returns a new sequence and leaves the old one unchanged
    const base = lazy([1, 2, 3]);
    const doubled = base.map((x) => x * 2);
    const big = doubled.filter((x) => x > 2);
    expect(base.toArray()).toEqual([1, 2, 3]);
    expect(doubled.toArray()).toEqual([2, 4, 6]);
    expect(big.toArray()).toEqual([4, 6]);
    expect(doubled.toArray()).toEqual([2, 4, 6]);
    
  8. take closes the source when it stops early
    let closed = false;
    function* letters() {
      try {
        yield "a";
        yield "b";
        yield "c";
      } finally {
        closed = true;
      }
    }
    const upper = lazy(letters())
      .map((x) => x.toUpperCase())
      .take(2);
    expect(upper.toArray()).toEqual(["A", "B"]);
    expect(closed).toBe(true);
    
  9. doesn't use the built-in iterator helpers
    const names = ["map", "filter", "take", "drop", "flatMap", "toArray", "reduce", "forEach", "some", "every", "find"];
    const proto = typeof Iterator === "function" ? Iterator.prototype : null;
    const saved = new Map();
    let result;
    let error;
    try {
      if (proto) {
        for (const name of names) {
          saved.set(name, Object.getOwnPropertyDescriptor(proto, name));
          proto[name] = () => {
            throw new Error("used the built-in Iterator.prototype." + name);
          };
        }
      }
      result = lazy([1, 2, 3, 4])
        .map((x) => x * 10)
        .filter((x) => x > 10)
        .take(2)
        .toArray();
    } catch (caught) {
      error = caught;
    } finally {
      for (const [name, descriptor] of saved) {
        if (descriptor) Object.defineProperty(proto, name, descriptor);
        else delete proto[name];
      }
    }
    expect(error).toBeUndefined();
    expect(result).toEqual([20, 30]);
    

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).