ESC

Type to search the knowledge base.

Array.flat Polyfill

Implement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.

intermediate7 min read
  • javascript
  • arrays
  • polyfill
  • interview

[].concat(...rows) looks like Array.prototype.flat until a table has tens of thousands of rows, or a hole in the middle of a nested list. Spread turns the outer array into a call’s argument list. Engines cap that list. You get a RangeError, a frozen tab, or (worse) a result that still contains empty slots.

Array.prototype.flat(depth) is a copying method. Default depth is 1. Nested values unwrap only when Array.isArray is true. Holes are skipped, not filled with undefined. Array-likes sitting inside the list stay as objects.

A whiteboard that only does reduce + concat fails those three checks. This page is the spec method. The interview “flatten this nested list with a stack” problem is Flatten a Nested Array.

Key takeaways

  • Default depth is 1. Infinity unwraps all the way. Negative depth becomes 0 (copy, do not unwrap).
  • Only real arrays unwrap. { 0, 1, length } nested inside is left intact. this itself may be array-like.
  • Holes disappear at every level you actually flatten. undefined and null are values and stay.
  • concat(...arr) is not a polyfill: it is depth 1, preserves inner holes, and blows up on huge inputs.

Depth is a number, holes disappear, arrays unwrap

Array.prototype.flat is generic. It does ToObject(this), reads length, then walks integer indexes. Elements unwrap only through IsArray (Array.isArray), not because they look nested.

[1, [2, [3]]].flat();          // [1, 2, [3]]
[1, [2, [3]]].flat(2);         // [1, 2, 3]
[1, [2, [3]]].flat(Infinity);  // [1, 2, 3]
[1, [2]].flat(-1);             // [1, [2]]  depth clamped to 0

depth goes through ToIntegerOrInfinity. Omitted or undefined means 1. NaN becomes 0. '2' becomes 2.

Holes are not values. flat uses HasProperty (the in check). Missing indexes are skipped. Nested arrays you unwrap are scanned the same way, so their holes vanish too. Nested arrays you do not unwrap keep their holes, because the array object is copied as one element.

[1, , 3].flat();                         // [1, 3]
[1, [2, , 4], 5].flat();                 // [1, 2, 4, 5]
[1, , 3, undefined, ['a', , ['d', , 'e']], null].flat();
// [1, 3, undefined, 'a', ['d', empty, 'e'], null]

undefined survived. The hole next to it did not. That distinction is the usual follow-up.

Non-array array-likes do not unwrap when they are elements:

const nestedLike = { 0: 3, 1: 4, length: 2 };
[[1, 2], nestedLike, 5].flat();
// [1, 2, { 0: 3, 1: 4, length: 2 }, 5]

this can still be array-like, because the method is generic:

Array.prototype.flat.call({ 0: [1, 2], 1: { 0: 3, length: 1 }, length: 2 });
// [1, 2, { 0: 3, length: 1 }]

Index 1 is an object, not an array, so it is appended as-is.

Spec-shaped implementation

Standalone function first. Assigning to Array.prototype.flat is interview convention and mutates a shared builtin.

function toIntegerOrInfinity(value) {
  const n = Number(value);
  if (Number.isNaN(n) || n === 0) return 0;
  if (!Number.isFinite(n)) return n; // Infinity or -Infinity
  return Math.trunc(n);
}

function lengthOfArrayLike(obj) {
  const len = Math.trunc(Number(obj.length));
  if (!Number.isFinite(len) || len <= 0) return 0;
  return Math.min(len, Number.MAX_SAFE_INTEGER);
}

function flattenIntoArray(target, source, sourceLen, start, depth) {
  let targetIndex = start;

  for (let sourceIndex = 0; sourceIndex < sourceLen; sourceIndex++) {
    if (!(sourceIndex in source)) continue; // hole: skip

    const element = source[sourceIndex];
    if (depth > 0 && Array.isArray(element)) {
      const nestedLen = lengthOfArrayLike(element);
      targetIndex = flattenIntoArray(
        target,
        element,
        nestedLen,
        targetIndex,
        depth - 1,
      );
    } else {
      target[targetIndex++] = element;
    }
  }

  return targetIndex;
}

function arrayFlat(input, depth) {
  const source = Object(input);
  const sourceLen = lengthOfArrayLike(source);
  let depthNum = 1;
  if (depth !== undefined) {
    depthNum = toIntegerOrInfinity(depth);
    if (depthNum < 0) depthNum = 0;
  }

  const result = [];
  flattenIntoArray(result, source, sourceLen, 0, depthNum);
  return result;
}

// Interview convention only:
// Array.prototype.flat = function flat(depth) {
//   return arrayFlat(this, depth);
// };

This matches the interesting parts of FlattenIntoArray: hole skip, IsArray gate, depth countdown. Infinity - 1 is still Infinity, so full flattening does not need a special case.

What this sketch omits: ArraySpeciesCreate. The real method builds the result through the source’s constructor species, so a subclass of Array can get a subclass instance back. Interviews almost never score that. Say you skipped it.

Recursion vs an explicit stack

FlattenIntoArray in the spec is recursive. A chain like [[[[…]]]] thousands deep will throw RangeError: Maximum call stack size exceeded in this polyfill. Native flat is implemented in C++ / Rust and is not bound by the JavaScript call stack the same way.

If the interviewer asks you to harden it, walk frames on a heap stack and keep left-to-right order:

function arrayFlatStacked(input, depth) {
  const source = Object(input);
  let depthNum = depth === undefined ? 1 : toIntegerOrInfinity(depth);
  if (depthNum < 0) depthNum = 0;

  const result = [];
  const stack = [
    { source, index: 0, len: lengthOfArrayLike(source), depth: depthNum },
  ];

  while (stack.length) {
    const frame = stack[stack.length - 1];
    if (frame.index >= frame.len) {
      stack.pop();
      continue;
    }
    const i = frame.index++;
    if (!(i in frame.source)) continue;
    const element = frame.source[i];
    if (frame.depth > 0 && Array.isArray(element)) {
      stack.push({
        source: element,
        index: 0,
        len: lengthOfArrayLike(element),
        depth: frame.depth - 1,
      });
    } else {
      result.push(element);
    }
  }

  return result;
}

Same answers as the recursive version for ordinary fixtures. The extra state is the current index on each nested array, so you do not reverse the list.

Why concat and spread fail as a polyfill

Approach Depth Holes Huge outer array
array.flat(n) n, default 1 skipped fine
[].concat(...array) 1 inner holes kept RangeError / stack blow-up
array.reduce((a, v) => a.concat(v), []) 1 outer holes skipped, inner holes kept allocates many intermediates
array.flatMap(x => x) 1, and maps skips holes like map fine for depth 1
[].concat([1, , 3]);     // [1, empty, 3]  holes preserved
[1, [2, , 4]].flat();    // [1, 2, 4]      holes dropped

const huge = Array.from({ length: 200_000 }, (_, i) => [i]);
// [].concat(...huge)  → RangeError in common engines
huge.flat();             // works

Spread and apply both expand the outer list into a single argument list. That is a hard engine limit, not a style issue. reduce + concat avoids the argument cap and still gets hole semantics wrong.

HasProperty also walks the prototype. A hole whose index exists on [[Prototype]] is treated as present:

const proto = [];
proto[1] = 'from-proto';
const arr = [1, , 3];
Object.setPrototypeOf(arr, proto);
arrayFlat(arr); // [1, 'from-proto', 3]

Rare in app code. Correct if you claimed spec behavior.

Procedure to say at the whiteboard

  1. Coerce this with Object(...), read length, default depth to 1.
  2. Allocate a new array (skip species unless asked).
  3. For each index, if index in source is false, skip.
  4. If depth > 0 and Array.isArray(element), flatten that element with depth - 1.
  5. Otherwise append the element, including null and undefined.
  6. Mention Infinity, negative clamp, array-like this, and why spread is illegal for large inputs.

Interview out-loud answer

flat copies the array and unwraps nested arrays up to depth, default 1. I only unwrap when Array.isArray is true, so a nested { 0, 1, length } stays an object. Holes are skipped with an in check; undefined is a real value and stays. I will not write [].concat(...arr) because it is one level, keeps inner holes, and dies on large lists. Recursion matches the spec helper; a heap stack is the version I would ship if depth can be hostile. Species construction I would name as omitted.

Further reading

Related guides