How Does map() Work in JavaScript
November 5, 2025
Developers use map() every day, but few understand what JavaScript is actually doing when you call it. Let's build it from scratch to find out.
What is map() Doing?
At its core, map() is simple: it creates a new array by running your function on each element.
1.const numbers = [1, 2, 3, 4, 5];2.const doubled = numbers.map(num => num * 2);3.4.console.log(doubled); // [2, 4, 6, 8, 10]5.console.log(numbers); // [1, 2, 3, 4, 5] - original unchanged
But HOW does it do this? What's JavaScript actually executing?
Building map() From Scratch
Let's create map() step-by-step, starting simple and adding complexity as we go.
Step 1: The Simplest Possible Version
Let's start with the absolute simplest version that just doubles numbers:
1.function simpleMap(array) {2. const result = [];3. for (let i = 0; i < array.length; i++) {4. result.push(array[i] * 2);5. }6. return result;7.}8.9.const numbers = [1, 2, 3];10.console.log(simpleMap(numbers)); // [2, 4, 6]
This works, but it's hardcoded to only double numbers. We need to make it flexible.
Step 2: Accept a Function as an Argument
Instead of hardcoding the transformation, let's accept a function:
1.function simpleMap(array, transformFunction) {2. const result = [];3. for (let i = 0; i < array.length; i++) {4. const transformed = transformFunction(array[i]);5. result.push(transformed);6. }7. return result;8.}9.10.// Now we can transform however we want!11.const numbers = [1, 2, 3];12.console.log(simpleMap(numbers, n => n * 2)); // [2, 4, 6]13.console.log(simpleMap(numbers, n => n * 3)); // [3, 6, 9]
This is the core concept: take a function, apply it to every element, build a new array with the results.
Step 3: Add It to Array.prototype
The real map() is a method on arrays. Let's add our function to Array.prototype:
1.Array.prototype.myMap = function(transformFunction) {2. const result = [];3. // 'this' refers to the array that called myMap()4. for (let i = 0; i < this.length; i++) {5. const transformed = transformFunction(this[i]);6. result.push(transformed);7. }8. return result;9.};10.11.const numbers = [1, 2, 3];12.console.log(numbers.myMap(n => n * 2)); // [2, 4, 6]
Array.prototype usethis to reference the array within the function.
Step 4: Add Index and Array Parameters
The real map() passes three arguments to your callback: element, index, and the array itself:
1.Array.prototype.myMap = function(callback) {2. const result = [];3. for (let i = 0; i < this.length; i++) {4. // Pass element, index, and the array itself5. const transformed = callback(this[i], i, this);6. result.push(transformed);7. }8. return result;9.};10.11.const letters = ['a', 'b', 'c'];12.const numbered = letters.myMap((letter, index) => {13. return `${index + 1}. ${letter}`;14.});15.console.log(numbered); // ['1. a', '2. b', '3. c']
Step 5: Handle thisArg (Optional Second Parameter)
map() accepts an optional second parameter that sets what this refers to inside the callback:
1.Array.prototype.myMap = function(callback, thisArg) {2. const result = [];3. for (let i = 0; i < this.length; i++) {4. // Call callback with the specified context5. const transformed = callback.call(thisArg, this[i], i, this);6. result.push(transformed);7. }8. return result;9.};10.11.const multiplier = { factor: 10 };12.const nums = [1, 2, 3];13.const out = nums.myMap(function (n) {14. return n * this.factor;15.}, multiplier);16.console.log(out); // [10, 20, 30]
That's it! We've built map() from scratch. The core mechanism is simple: create an empty array, loop through the original, call the callback on each element, and collect the results. Everything else: index parameters, thisArg, property checking, are just extras built on top of that core.
What's JavaScript Actually Doing?
Now that we understand the mechanism, let's trace through a concrete example to see exactly what JavaScript executes when you call map(). This will show you what's happening step-by-step in memory:
1.const numbers = [10, 20, 30];2.const doubled = numbers.map(x => x * 2);3.4.// Step-by-step execution:5.6.// 1. JavaScript creates new empty array in memory: result = []7.8.// 2. Loop iteration 1 (i = 0):9.// - Access numbers[0] which is 1010.// - Call your function: x => x * 2 with x = 1011.// - Function returns: 2012.// - Push 20 to result: result = [20]13.14.// 3. Loop iteration 2 (i = 1):15.// - Access numbers[1] which is 2016.// - Call your function with x = 2017.// - Function returns: 4018.// - Push 40 to result: result = [20, 40]19.20.// 4. Loop iteration 3 (i = 2):21.// - Access numbers[2] which is 3022.// - Call your function with x = 3023.// - Function returns: 6024.// - Push 60 to result: result = [20, 40, 60]25.26.// 5. Loop ends, return result27.// doubled = [20, 40, 60]
Length is Captured at Start
One interesting note, is that map() captures the array length BEFORE it starts looping. This means if you modify the array during mapping, those changes won't affect what gets visited:
1.const a = [1, 2, 3];2.const out = a.map((x, i) => {3. if (i === 0) a.push(4); // Add element during mapping4. return x * 2;5.});6.7.console.log(out); // [2, 4, 6] - the 4 wasn't visited8.console.log(a); // [1, 2, 3, 4] - but it was added
Why does this happen? Because map() reads array.length once at the start (capturing it as 3 in this example), then iterates from 0 to 2. By the time the callback runs for index 0, it's too late to change what gets visited because the loop range was already determined.
Holes: Why map() Checks Property Existence
map() iterates over indices, but on each index it first asks “does this index exist as a property (own or inherited)?” If not, it doesn't call your function and leaves a hole at the same index in the output. If it exists, it calls your function and assigns to the same index in the new array.
What are "holes"?
A hole is an empty slot in an array. It's different from undefined:
1.// These look similar but are different2.const hasUndefined = [1, undefined, 3]; // index 1 EXISTS, value is undefined3.const hasHole = [1, , 3]; // index 1 DOESN'T EXIST (hole)4.5.console.log(1 in hasUndefined); // true - index 1 exists6.console.log(1 in hasHole); // false - index 1 is a hole7.8.// You can also create holes with delete9.const arr = [1, 2, 3];10.delete arr[1]; // Creates a hole at index 111.console.log(arr); // [1, <1 empty item>, 3]
map() skips holes, meaning it won't call your callback for them. The hole is preserved in the output:
1.const sparse = [1, , 3];2.const doubled = sparse.map(x => x * 2);3.console.log(doubled); // [2, <1 empty item>, 6] - hole preserved
Here's what map() is doing internally to skip holes:
1.// This is what map() is doing internally:2.Array.prototype.myMap = function(callback) {3. const result = [];4. for (let i = 0; i < this.length; i++) {5. if (i in this) { // Check if property exists!6. // Only call callback if the property exists7. result[i] = callback(this[i], i, this);8. }9. }10. result.length = this.length; // Preserve array length11. return result;12.};
The key is if (i in this). That in operator checks whether the property exists. For holes, it returns false, so the callback never runs.
Inherited Properties: An Unexpected Consequence
Now here's where it gets interesting. The in operator doesn't just check if a property exists on the array itself. It checks the ENTIRE prototype chain. This leads to a weird edge case:map() will visit indices that are inherited from Array.prototype.
To understand this, you need to know that arrays inherit from Array.prototype. Normally that just means they inherit methods like map and push. But you can also add numeric indices to the prototype:
1.// Weird edge case (don't do this in real code!)2.Array.prototype[1] = "from prototype";3.const arr = ["a", , "c"]; // hole at index 14.5.const out = arr.map(v => v);6.console.log(out[1]); // "from prototype" - callback WAS called!7.8.delete Array.prototype[1]; // Clean up
Why did the callback get called? Because 1 in arr returns true. The in operator checks the entire prototype chain, finds the property on Array.prototype[1], and considers it present. So map() calls your callback even though the array itself has a hole there.
Frequently Asked Questions
What does map() return?
A new Array. This is not the ES6 Map collection.
Can map() return something that is not a plain Array?
Yes, when you call it on an Array subclass. To build the result,map() picks a constructor based on the receiver (the spec calls this ArraySpeciesCreate). By default it uses the subclass constructor, unless the subclass overrides constructor[Symbol.species] to force a plain Array.
1.// Plain arrays -> Array2.const arr = [1, 2, 3];3.const doubled = arr.map(x => x * 2);4.console.log(doubled instanceof Array); // true5.6.// Subclass default: result keeps the subclass7.class Sub extends Array {}8.const r1 = new Sub(1, 2, 3).map(x => x);9.console.log(r1 instanceof Sub); // true10.11.// Subclass override: force plain Array results12.class ForceArray extends Array {13. static get [Symbol.species]() { return Array; }14.}15.const r2 = new ForceArray(1, 2, 3).map(x => x);16.console.log(r2 instanceof ForceArray); // false17.console.log(r2 instanceof Array); // true
Any performance tips when using map()?
Keep arrays packed (no holes) and types consistent. V8 runs packed, type-stable arrays faster than holey or mixed-type arrays.
1.// Fast: packed + consistent types2.const fast = [1, 2, 3].map(x => x * 2);3.4.// Slower: holes degrade performance5.const holey = [1, , 3].map(x => x * 2);6.7.// Slower: mixed types cause transitions8.const mixed = [1, 2, 3].map(x => (x === 2 ? { val: x } : x));
Summary
At its core, map() is simple:
- Capture the array length at the start
- Create a new empty array
- Loop through each index (0 to length-1)
- Check if the property exists (using
in) - If it exists, call your function on that element
- Assign the result to the same index in the new array (holes preserved)
- Return the new array

