Polyfill array methods
Write spec-aware JavaScript polyfills for map, filter, reduce, and flat, including sparse arrays, thisArg, array-likes, and safe prototype demos.
- 01Rebuild the core array methodsImplement real standalone
myMap,myFilter,myReduce, andmyFlatfunctions before discussing prototype methods. - 02Handle spec edge casesPreserve or skip holes correctly, bind
thisArg, capture length once, support array-likes, and throw the same reduce error. - 03Test like a polyfill authorRun native methods and your versions side-by-side on sparse arrays, mutations, bad callbacks, and deep flattening.
Why rebuild built-ins?
A polyfill is code that provides a standard feature when an environment does not have it yet. In this lesson we rebuild map, filter, reduce, and flat as standalone functions so you can see the rules that native methods hide.
This is the first lesson in Build it from scratch. The point is understanding: rebuilding a familiar feature makes callback arguments, sparse arrays, errors, and spec language concrete. Interviewers also like these questions because small omissions reveal whether someone knows the real behavior or only the happy path.
Do not ship lesson polyfills instead of the platform. In production, prefer native built-ins, targeted transpilation, and vetted packages such as core-js through a build setup. The Transpilers and polyfills lesson explains that toolchain.
You understand a bicycle differently after you remove the wheel, clean the chain, and adjust the brakes. Polyfilling does the same for built-ins: it exposes the small rules you rely on every day.
- In real life: Remove the wheel and inspect the hub
- In JavaScript: Write the loop and inspect each callback argument
- In real life: Notice the brake cable routing
- In JavaScript: Handle holes,
thisArg, errors, and length capture - In real life: Put the bicycle back together
- In JavaScript: Run native and polyfill behavior side-by-side
Where the analogy stops: Taking apart a bicycle teaches the mechanism. It does not mean your hand-built parts are safer than factory-tested parts on a real road.
Read this lesson after map, filter & reduce and Higher-order functions. It also links back to Array basics, Iterating arrays, Recursion, Property descriptors, Native prototypes, and Manuals and specs exactly where those ideas matter.
Standalone polyfills first
REAL FUNCTIONSStart with standalone functions. They are easy to test, they do not mutate global prototypes, and they make the receiver explicit. Each function begins with the same three habits: convert the input with a ToObject-style helper, capture an array-like length once, and reject a non-function callback before iterating.
function toObject(value) { if (value == null) { throw new TypeError("Cannot convert undefined or null to object"); } return Object(value);} function toLength(value) { const number = Number(value); if (!Number.isFinite(number) || number <= 0) return 0; return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER);} function toDepth(value) { const number = Number(value); if (number === Infinity) return Infinity; if (!Number.isFinite(number) || number <= 0) return 0; return Math.floor(number);}ToObject is why array methods can work on strings and array-like objects, but reject null and undefined. LengthOfArrayLike is why appending during iteration does not extend the loop.
myMapfunction myMap(arrayLike, callback, thisArg) { const object = toObject(arrayLike); const length = toLength(object.length); if (typeof callback !== "function") { throw new TypeError(String(callback) + " is not a function"); } const result = new Array(length); for (let index = 0; index < length; index += 1) { if (index in object) { result[index] = callback.call(thisArg, object[index], index, object); } } return result;}Line 7 creates the output at the captured length. Line 9 is the sparse-array rule: only present properties call the callback. Line 10 passes value, index, and the array-like object, while binding the optional thisArg.
myFilterfunction myFilter(arrayLike, callback, thisArg) { const object = toObject(arrayLike); const length = toLength(object.length); if (typeof callback !== "function") { throw new TypeError(String(callback) + " is not a function"); } const result = []; for (let index = 0; index < length; index += 1) { if (index in object) { const value = object[index]; if (callback.call(thisArg, value, index, object)) { result.push(value); } } } return result;}filter does not preserve indexes. When the callback returns a truthy value, the original value is appended to the next dense result index.
myReducefunction myReduce(arrayLike, callback, initialValue) { const object = toObject(arrayLike); const length = toLength(object.length); if (typeof callback !== "function") { throw new TypeError(String(callback) + " is not a function"); } let index = 0; let accumulator = initialValue; if (arguments.length < 3) { while (index < length && !(index in object)) index += 1; if (index >= length) { throw new TypeError("Reduce of empty array with no initial value"); } accumulator = object[index]; index += 1; } for (; index < length; index += 1) { if (index in object) { accumulator = callback(accumulator, object[index], index, object); } } return accumulator;}reduce is different because it may not have an initial accumulator. If no initial value is passed, the first present element becomes the accumulator. If there is no present element, the native error message must be thrown.
map, filter, holes, and thisArg
STEP THROUGHNative map preserves holes and skips callbacks for them. A sparse slot is not the same as a slot containing undefined. The spec checks whether the property exists before reading it.
map skips holes and preserves themconst sparse = [1, , 3];const calls = [];const mapped = sparse.map((value, index) => { calls.push(index); return value * 2;}); console.log(calls.join(","));console.log(0 in mapped, 1 in mapped, 2 in mapped);console.log(mapped);The first log is 0,2, proving index 1 never called the callback. The second log is true false true, proving the mapped result still has a hole at index 1.
Step through the standalone myMap. Watch the callback arguments, the hole check, and the thisArg binding.
script
const context = { bonus: 10 };const result = myMap(sparse, function (value, index, array) { return value + index + this.bonus;}, context);console.log(0 in result, 1 in result, result);filter also skips holes, but it compacts passing values into a dense result. That difference is why map can keep the same length while filter often returns a shorter array.
Step through myFilter. Holes do not call the callback, and passing values compact into a dense result.
script
const kept = myFilter(sparse, function (value, index) { return value >= 2;});console.log(kept);thisArg works with regular functions, not arrowsconst context = { bonus: 10 }; const withFunction = myMap([1], function (value) { return value + this.bonus;}, context); const runner = { bonus: 100, run() { return myMap([1], value => value + this.bonus, context); },}; console.log(withFunction);console.log(runner.run());A regular callback gets this from callback.call(thisArg, ...), so the first log is [11]. The arrow ignores that call binding and captures the surrounding receiver as this, so the second log is [101].
reduce and the first accumulator
EXACT ERRORreduce has the trickiest start. With an initial value, every present item reaches the callback. Without one, the first present item becomes the accumulator and callbacks begin after it. Holes are skipped in both paths.
Switch reduce modes and step through how the accumulator starts.
script
const total = myReduce(prices, (acc, price, index) => { return acc + price;}, 0);console.log(total);try { [].reduce((acc, value) => acc + value);} catch (error) { console.log(error.name + ": " + error.message);} try { myReduce([], (acc, value) => acc + value);} catch (error) { console.log(error.name + ": " + error.message);}Both lines print TypeError: Reduce of empty array with no initial value. Matching that exact message is useful in tests because it proves the polyfill took the same no-accumulator path.
flat depth and recursion
RECURSION VS STACKflat copies values into a new dense array. When it sees an array and the depth is above zero, it opens that array and continues. The default depth is 1; Infinity keeps descending.
myFlatfunction myFlat(arrayLike, depth = 1) { const result = []; flattenInto(result, toObject(arrayLike), toDepth(depth)); return result;} function flattenInto(target, source, depth) { const length = toLength(source.length); for (let index = 0; index < length; index += 1) { if (!(index in source)) continue; const value = source[index]; if (Array.isArray(value) && depth > 0) { flattenInto(target, value, depth === Infinity ? Infinity : depth - 1); } else { target.push(value); } }}Recursion reads like the definition: flatten this source, and when a nested array appears, flatten that source too with one less depth. For very deep input, an explicit stack avoids using the JavaScript call stack.
myFlatWithStackfunction myFlatWithStack(arrayLike, depth = 1) { const root = toObject(arrayLike); const result = []; const stack = [{ source: root, index: 0, length: toLength(root.length), depth: toDepth(depth), }]; while (stack.length > 0) { const frame = stack.pop(); if (frame.index >= frame.length) continue; const index = frame.index; frame.index += 1; stack.push(frame); if (!(index in frame.source)) continue; const value = frame.source[index]; if (Array.isArray(value) && frame.depth > 0) { stack.push({ source: value, index: 0, length: toLength(value.length), depth: frame.depth === Infinity ? Infinity : frame.depth - 1, }); } else { result.push(value); } } return result;}flatconst nested = [1, [2, [3, [4]]], , 5]; console.log(myFlat(nested));console.log(myFlat(nested, 2));console.log(myFlat(nested, Infinity));console.log(myFlatWithStack(nested, Infinity));Compare recursive flattening with an explicit stack, and change the depth to see why Infinity matters.
script
const result = myFlat(nested, 1);console.log(result);Matching the spec
ECMA-262The ECMA-262 algorithm for Array.prototype.map is more precise than “loop and push.” Read the spec names as a checklist. The exact document can look formal, but the steps map cleanly onto the code you just wrote.
1. ToObject(this value)2. LengthOfArrayLike(O)3. If IsCallable(callbackfn) is false, throw a TypeError4. ArraySpeciesCreate(O, len)5. For each index k from 0 to len - 1:6. If HasProperty(O, Pk), Get(O, Pk) and call callbackfn7. CreateDataPropertyOrThrow(A, Pk, mappedValue)ArraySpeciesCreate is why subclassed arrays can choose the constructor used for the result. These standalone polyfills return normal arrays, but knowing the step exists keeps you honest when reading Manuals and specs.
`ToObject(this value)``LengthOfArrayLike(O)``IsCallable(callbackfn)``HasProperty(O, Pk)``Get(O, Pk)``CreateDataPropertyOrThrow(A, Pk, mappedValue)`- Append to the next result index
Place each step by when it happens: setup, per-index iteration, or result writing.
const values = [1, 2];const native = values.map((value, index, array) => { if (index === 0) array.push(99); return value * 2;}); const polyfilled = myMap([1, 2], (value, index, array) => { if (index === 0) array[2] = 99; return value * 2;}); console.log(native);console.log(values);console.log(polyfilled);Both native and polyfill versions map only the original two indexes. Appending during iteration mutates the source, but it does not extend the loop.
const letters = { length: 2, 0: "a", 1: "b" }; const native = Array.prototype.map.call( letters, (value, index) => value + index,);const polyfilled = myMap(letters, (value, index) => value + index); console.log(native);console.log(polyfilled);An object with a length and numeric properties can be a receiver. That matters when designing APIs, working with DOM lists, or reading older JavaScript that borrows array methods with call.
Prototype safety
Real polyfills sometimes install a method on Array.prototype. That is dangerous in application code: you can collide with future standards, break libraries that loop over inherited properties, or make tests order-dependent. Keep prototype patches inside controlled build tools and always feature check first.
The runnable snippet below patches Array.prototype only inside an immediately invoked function, makes the method non-enumerable with property descriptors, and restores or deletes the method before it exits. The lesson TypeScript modules never patch native prototypes.
(() => { const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, "myMap"); const previous = Array.prototype.myMap; if (typeof Array.prototype.myMap !== "function") { Object.defineProperty(Array.prototype, "myMap", { value(callback, thisArg) { return myMap(this, callback, thisArg); }, enumerable: false, writable: true, configurable: true, }); } console.log([1, 2].myMap(value => value * 3)); console.log(Object.keys(Array.prototype).includes("myMap")); if (hadOwn) { Object.defineProperty(Array.prototype, "myMap", { value: previous, enumerable: false, writable: true, configurable: true, }); } else { delete Array.prototype.myMap; }})();The first log proves the method worked. The second log is false, proving myMap is not enumerable. Learn the prototype rules in Native prototypes, but avoid patching them in normal app code.
Native vs polyfill lab
TEST TABLEPolyfills are only believable when they match native behavior on uncomfortable inputs. The lab below runs each case twice: once with the native method and once with the lesson function.
const input = [1, , 3];console.log(input.map(value => value * 2));console.log(myMap(input, value => value * 2));- native
[2, <hole>, 6]polyfill[2, <hole>, 6]same behaviortrue
Use this table when you change a polyfill. A good test compares shapes, holes, errors, and mutation timing rather than only one happy-path array.
HasProperty skips the callback but the result keeps the empty slot.
| Case | Native behavior | Polyfill rule |
|---|---|---|
Sparse map | Callback skips holes; output keeps holes | Use index in object before Get, then write at the same index |
Sparse filter | Callback skips holes; output is dense | Use the same hole check, then append passing values |
Empty reduce | Throws the exact TypeError message | Find the first present element or throw before looping |
| Mutation during iteration | New indexes past captured length are ignored | Store length before the loop |
| Array-like receiver | Works with length and numeric keys | Use ToObject and numeric property access |
| Non-callable callback | Throws TypeError before iterating | Check typeof callback === 'function' early |
Practical use and misconceptions
Rebuilding methods makes you a sharper debugger. You will recognize why a callback did not run, why thisArg did nothing, why an empty reducer crashed, and why a NodeList can borrow an array method. You will also write better tests for utilities that accept array-like inputs.
const rows = [ { name: "Ada", skills: ["math", "js"] }, { name: "Lin", skills: [] },]; const skillLabels = myFlat( myMap(rows, row => myMap(row.skills, skill => row.name + ": " + skill)),); console.log(skillLabels);| Method | Job | Result shape | Polyfill detail |
|---|---|---|---|
map | Transforms each present item | Preserves length and holes | Callback receives value, index, and array; optional thisArg |
filter | Keeps present items whose callback returns truthy | Returns a dense array | Holes never call the callback and never appear in the result |
reduce | Folds present items into one accumulator | Returns any type | Without an initial value, first present item becomes the accumulator |
flat | Copies values while opening nested arrays | Returns a dense array | Default depth is 1; Infinity keeps descending |
| Approach | What it does | Useful for | Problem |
|---|---|---|---|
Quick forEach wrapper | Calls the native method or loops over values | Great for explaining a happy path | Misses holes, array-likes, length capture, thisArg, and TypeErrors |
| Spec-faithful standalone function | Uses ToObject, LengthOfArrayLike, IsCallable, HasProperty, and explicit writes | Best learning target and safest to test | Still not a full engine: species constructors and realm details are simplified |
| Prototype patch | Adds a method to Array.prototype | Only needed for real polyfills in controlled builds | Can break enumeration and other libraries if not feature-checked and non-enumerable |
“A polyfill is just a wrapper around forEach.”
A wrapper can teach the happy path, but a real polyfill must handle holes, length capture, TypeErrors, array-likes, and callback binding.
“A hole is the same as undefined.”
A slot containing undefined is present and calls the callback. A hole is absent and is skipped by HasProperty.
“Arrow callbacks can use thisArg.”
Arrows capture this lexically. Use a regular function when the array method receives a thisArg.
“Prototype patches are harmless if the name is unique.”
A unique name today can become tomorrow's standard or collide with another library. Patch only in isolated demos or vetted polyfill packages.
Practice exercises
5 EXERCISESmyMap handles holesRun the program and type the two printed facts separated by a pipe.
const sparse = [1, , 3];
const calls = [];
const doubled = myMap(sparse, (value, index) => {
calls.push(index);
return value * 2;
});
console.log(calls.join(","));
console.log(0 in doubled, 1 in doubled, 2 in doubled);const sparse = [1, , 3];
const calls = [];
const doubled = myMap(sparse, (value, index) => {
calls.push(index);
return value * 2;
});
console.log(calls.join(","));
console.log(0 in doubled, 1 in doubled, 2 in doubled);The calls log is 0,2, and the presence checks are true false true.
myFilter usageWrite the callback that keeps even numbers. What text prints?
function onlyEven(values) {
return myFilter(values, value => value % 2 === 0);
}
console.log(onlyEven([1, 2, 3, 4]).join(","));function onlyEven(values) {
return myFilter(values, value => value % 2 === 0);
}
console.log(onlyEven([1, 2, 3, 4]).join(","));myFilter appends only values whose callback result is truthy, so the even values print as 2,4.
Explain what the fixed reducer should print when no initial value is supplied.
// A broken reducer would start accumulator as undefined here.
function sum(values) {
return myReduce(values, (total, value) => total + value);
}
console.log(sum([4, 5]));function sum(values) {
return myReduce(values, (total, value) => total + value);
}
console.log(sum([4, 5]));myReduce finds 4 as the first accumulator, then calls the reducer with 5. The fixed output is 9.
myFlatMap from your piecesUse the completed polyfills to build one more array method.
function myFlatMap(arrayLike, callback, thisArg) {
// Use myMap, then flatten one level.
return [];
}
console.log(myFlatMap(["js", "css"], word => word.split("")).join(","));function myFlatMap(arrayLike, callback, thisArg) {
return myFlat(myMap(arrayLike, callback, thisArg), 1);
}
console.log(myFlatMap(["js", "css"], word => word.split("")).join(","));myMap turns words into arrays of letters. myFlat(..., 1) opens one level, so the output is j,s,c,s,s.
Run the isolated patch demo. What does it prove?
(() => {
const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, "myMap");
const previous = Array.prototype.myMap;
if (typeof Array.prototype.myMap !== "function") {
Object.defineProperty(Array.prototype, "myMap", {
value(callback, thisArg) {
return myMap(this, callback, thisArg);
},
enumerable: false,
writable: true,
configurable: true,
});
}
console.log([1, 2].myMap(value => value * 3));
console.log(Object.keys(Array.prototype).includes("myMap"));
if (hadOwn) {
Object.defineProperty(Array.prototype, "myMap", {
value: previous,
enumerable: false,
writable: true,
configurable: true,
});
} else {
delete Array.prototype.myMap;
}
})();(() => {
const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, "myMap");
const previous = Array.prototype.myMap;
if (typeof Array.prototype.myMap !== "function") {
Object.defineProperty(Array.prototype, "myMap", {
value(callback, thisArg) {
return myMap(this, callback, thisArg);
},
enumerable: false,
writable: true,
configurable: true,
});
}
console.log([1, 2].myMap(value => value * 3));
console.log(Object.keys(Array.prototype).includes("myMap"));
if (hadOwn) {
Object.defineProperty(Array.prototype, "myMap", {
value: previous,
enumerable: false,
writable: true,
configurable: true,
});
} else {
delete Array.prototype.myMap;
}
})();The demo maps [1, 2] to [3, 6], then proves the method is not enumerable by printing false.
Quiz: check your understanding
8 QUESTIONSPredict from the algorithm, not from memory. The explanations name the spec rule each answer depends on.
Question 1 of 8Why does this module rebuild built-ins instead of recommending custom replacements in production?
Choose an answer to see the explanation.
Question 2 of 8What does sparse map example print first?
Read the code, then predictconst calls = []; [1, , 3].map((value, index) => calls.push(index)); console.log(calls.join(","));Choose an answer to see the explanation.
Question 3 of 8What exact message does empty reduce without an initial value print here?
Read the code, then predicttry { [].reduce((acc, value) => acc + value); } catch (error) { console.log(error.message); }Choose an answer to see the explanation.
Question 4 of 8Which callback can use the
thisArgpassed tomyMap(values, callback, thisArg)?Choose an answer to see the explanation.
Question 5 of 8What does the length-capture example print first?
Read the code, then predictconst values = [1, 2]; const result = values.map((value, index, array) => { if (index === 0) array.push(99); return value * 2; }); console.log(result);Choose an answer to see the explanation.
Question 6 of 8What does
flat(Infinity)do thatflat()does not?Choose an answer to see the explanation.
Question 7 of 8Why does
Array.prototype.map.call({ length: 2, 0: 'a', 1: 'b' }, fn)work?Choose an answer to see the explanation.
Question 8 of 8Which
Object.definePropertysettings belong in a prototype demonstration?Choose an answer to see the explanation.
Key takeaways
- Build standalone functions first; patching prototypes is a separate, risky demonstration.
mappreserves holes, whilefilter,reduce, andflatskip them.- Array callbacks receive
value,index, andarray; regular functions can usethisArg. reducewithout an initial value uses the first present element or throws the native TypeError on empty arrays.- Spec-faithful polyfills test array-likes, mutation during iteration, non-callable callbacks, holes, and deep flattening.
One-line summary.
A good array-method polyfill is a small spec checklist: object, length, callable callback, property check, value read, and a carefully shaped result.
Up next in this module: rebuilding call, apply, and bind, where the focus shifts from array receivers to function receivers and explicit this control.