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.
- 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.Infinityunwraps all the way. Negative depth becomes0(copy, do not unwrap).- Only real arrays unwrap.
{ 0, 1, length }nested inside is left intact.thisitself may be array-like.- Holes disappear at every level you actually flatten.
undefinedandnullare 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
- Coerce
thiswithObject(...), readlength, defaultdepthto1. - Allocate a new array (skip species unless asked).
- For each index, if
index in sourceis false, skip. - If
depth > 0andArray.isArray(element), flatten that element withdepth - 1. - Otherwise append the element, including
nullandundefined. - Mention
Infinity, negative clamp, array-likethis, and why spread is illegal for large inputs.
Interview out-loud answer
flatcopies the array and unwraps nested arrays up todepth, default1. I only unwrap whenArray.isArrayis true, so a nested{ 0, 1, length }stays an object. Holes are skipped with anincheck;undefinedis 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.
Related on this site
- Flatten a Nested Array: iterative flatten as a different prompt
- Array.reduce Polyfill: why
reduce+concatis a trap here - Polyfills and Baseline Targets: when a builtin shim belongs in a bundle
- Rest and Spread Syntax: why spreading a huge array is a call-site limit
- JavaScript Interview Learning Path: where array polyfills sit in the cluster
Further reading
Related guides
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.forEach PolyfillImplement Array.prototype.forEach: visit present elements, skip holes, ignore the callback return, and honor thisArg without using native forEach.
- Array.includes PolyfillImplement Array.prototype.includes with SameValueZero: NaN is found, +0 equals -0, and fromIndex can walk backwards from the end.
- Array.map PolyfillImplement Array.prototype.map without cheating: thisArg, sparse holes, callback args, and why indexOf-based loops fail on duplicates and NaN.