Lodash toArray with Custom ES6 Class Iterables

This article examines how the Lodash utility function _.toArray detects and consumes the ECMAScript 2015 (ES6) iterable protocol on instances of custom classes. It breaks down the internal sequence Lodash uses to inspect the Symbol.iterator method, consume iterator streams via low-level helper functions, and resolve conflicts between custom iterables and array-like objects.

The Entry Point: _.toArray

When you pass an argument to _.toArray(value), Lodash first evaluates the falsiness of the input. If the value is null, undefined, or otherwise falsey, it immediately returns an empty array [].

For non-falsey inputs, Lodash evaluates the type and structural properties of the target to decide between three primary conversion strategies:

  1. Array-like objects: Handled via index copying.
  2. ES6 Iterables: Handled via iterator extraction and consumption.
  3. Plain objects: Handled by extracting enumerable values via values(value).

Detecting the Iterable Protocol

Custom ES6 classes qualify as iterables by implementing the standard iteration protocol via the well-known symbol Symbol.iterator. A typical class definition looks like this:

class NumberSequence {
  constructor(limit) {
    this.limit = limit;
  }

  *[Symbol.iterator]() {
    for (let i = 1; i <= this.limit; i++) {
      yield i;
    }
  }
}

When an instance of this class is passed to _.toArray, Lodash checks if the object conforms to the iterable protocol. Internally, Lodash checks if Symbol.iterator is defined on the instance or its prototype chain:

const iterator = typeof value[Symbol.iterator] === 'function' 
  ? value[Symbol.iterator]() 
  : undefined;

If the function exists, Lodash invokes it with no arguments to obtain the iterator object, which must implement the standard .next() method.

The Consumption Phase: iteratorToArray

Once Lodash obtains the iterator, it delegates the conversion to an internal helper function called iteratorToArray.

The iteratorToArray function executes a standard while loop over the iterator:

function iteratorToArray(iterator) {
  let data;
  const result = [];

  while (!(data = iterator.next()).done) {
    result.push(data.value);
  }
  return result;
}

During this execution:

  1. iterator.next() is called repeatedly.
  2. Each emitted iteration result object is checked for the boolean done property.
  3. If done is false (or falsey), data.value is appended to the internal array.
  4. When done: true is encountered, iteration stops, and the populated array is returned.

The isArrayLike Conflict

A critical nuance in Lodash's resolution order is how it handles the isArrayLike check.

Before inspecting for Symbol.iterator, _.toArray checks whether the target satisfies isArrayLike(value):

function isArrayLike(value) {
  return value != null && typeof value !== 'function' && isLength(value.length);
}

If an ES6 custom class explicitly defines a valid length property (a non-negative safe integer), Lodash treats the instance as an array-like object rather than an iterable:

class CustomCollection {
  constructor() {
    this.length = 2;
    this[0] = 'a';
    this[1] = 'b';
  }

  *[Symbol.iterator]() {
    yield 'x';
    yield 'y';
  }
}

_.toArray(new CustomCollection()); // Returns ['a', 'b'], bypassing the iterator

In this scenario, Lodash uses its internal copyArray or slice logic based on indexed properties, completely bypassing Symbol.iterator.

Summary of the Resolution Flow

For an instance of a custom ES6 class:

  1. Existence Check: If the class instance is null or undefined, return [].
  2. Array-Like Test: If the instance has a numeric length property within bounds, copy its indexed properties (0 through length - 1).
  3. Iterator Protocol Test: If not array-like, check for typeof value[Symbol.iterator] === 'function'.
  4. Iterator Drain: Invoke the method to get the iterator and drain it into a new array using iteratorToArray.
  5. Fallback: If no iterator exists, treat the instance as a generic object and return an array of its own enumerable property values via baseValues.