The Only JavaScript Article You'd Ever Need - 6/7
JavaScript Extras (Mostly for Interviews)
If you’re coming here from the previous articles, treat yourself with something because it’s been TMI over TMI over TMI over…you get the point. You now understand complex concepts about JS, the Event Loop, can work with Promises, and grasp advanced shit. That’s impressive progress.
But there’s a significant difference between knowing how to use a tool and understanding how to build it. Many developers can use .map(), but fewer can recreate it from scratch or explain exactly why it works the way it does.
Today, we transition from using existing tools to understanding how to build them. We’re looking inside the black box to discover it’s well-organized code.
Polyfills
Before we build, let’s understand why you’d need to. Consider yourself in an interview (very rare scenario that you even get to be an interview π΅βπ«). The interviewer asks you to build a custom implementation of an already built-in method, like map(), filter(), etc. Or consider the challenge of running modern JS code on older browsers. If you tried to run ES2020 code on Internet Explorer 8, the browser would fail because it doesn’t recognize modern features like Promise, fetch, or even Array.prototype.map.
You can’t control which browser users use. But you can provide fallback implementations for missing features. That’s a polyfill.
A polyfill is JS code that checks if a feature exists in the browser. If it doesn’t, the code provides its own implementation, effectively “filling in” the gaps in older environments.
The core concept is straightforward, it’s just a conditional statement.
// This is the core logic of EVERY polyfill
if (!SomeModernFeature) {
// If the feature doesn't exist...
// ...write our own version of it here.
SomeModernFeature = function (/*...*/) {
// Our custom, backward-compatible logic
};
}
Now, let’s implement some of the fundamental features you use every day.
Building Your Own .map()
What does it actually do? The .map() method iterates over an array, calls a function you provide for every single element, and returns a new array of the same length, containing the results of that function. It transforms each element into something new.
The Intuition
- We need to add our method, let’s call it
myMap, to theArray.prototypeso all arrays can use it. - Our function will be called on an array (
thiswill be the array itself). - It needs to accept one argument: a
callbackfunction. - Inside, it must create a new, empty array to store the results.
- It has to loop through the original array (
this). - For each element, it must call the
callback, passing it the element, its index, and the original array (the officialmapsignature iscallback(element, index, array)). - It takes whatever the
callbackreturns and pushes it into our new results array. - Finally, it must return the new results array.
Building It
// We'll wrap it in a polyfill structure.
if (!Array.prototype.myMap) {
Array.prototype.myMap = function (callback) {
// First, some sanity checks. What if 'this' is not an array?
// Or what if the callback isn't a function?
if (this == null) {
throw new TypeError("this is null or not defined");
}
if (typeof callback !== "function") {
throw new TypeError(callback + " is not a function");
}
// `this` refers to the array the method is called on.
const originalArray = this;
const newArray = []; // Our shiny new array for the results.
// Loop through the array.
for (let i = 0; i < originalArray.length; i++) {
// It's good practice to check if the index actually exists.
// This handles sparse arrays (e.g., [1, , 3]) correctly.
if (i in originalArray) {
// Call the callback and store its result.
const result = callback(originalArray[i], i, originalArray);
newArray[i] = result;
}
}
return newArray;
};
}
// LET'S TEST OUR CREATION
const numbers = [1, 4, 9, 16];
const roots = numbers.myMap((num) => Math.sqrt(num));
console.log(roots); // [1, 2, 3, 4]
// Success! You've implemented one of JavaScript's most fundamental methods.
Building Your Own .filter()
What does it actually do? The .filter() method iterates over an array and runs a “predicate” function (a callback that returns true or false) on each element. It returns a new array containing only the elements for which the predicate returned a truthy value.
The Intuition
This is almost identical to map, but with one crucial difference in the loop’s logic.
- Setup is the same: attach
myFiltertoArray.prototype. - It takes a
callback(the predicate). - Create an empty
newArray. - Loop through the original array.
- For each element, call the
callback. - The Key Step:
ifthe callback returnstrue, push the original element into thenewArray. If it returnsfalse, do absolutely nothing. - Return the
newArray.
Building It
if (!Array.prototype.myFilter) {
Array.prototype.myFilter = function (callback) {
if (this == null) {
throw new TypeError("this is null or not defined");
}
if (typeof callback !== "function") {
throw new TypeError(callback + " is not a function");
}
const originalArray = this;
const filteredArray = [];
for (let i = 0; i < originalArray.length; i++) {
if (i in originalArray) {
// Call the predicate.
const shouldKeep = callback(originalArray[i], i, originalArray);
// If it returned true, keep the original element.
if (shouldKeep) {
filteredArray.push(originalArray[i]);
}
}
}
return filteredArray;
};
}
// LET'S TEST OUR IMPLEMENTATION
const words = [
"spray",
"limit",
"elite",
"exuberant",
"destruction",
"present",
];
const longWords = words.myFilter((word) => word.length > 6);
console.log(longWords); // ["exuberant", "destruction", "present"]
// You've successfully implemented array filtering functionality.
Building Promise.all
You’ve built synchronous tools. Now let’s tackle the asynchronous functionality you learned about earlier.
What does it actually do? Promise.all takes an array of promises. It returns a single new promise that resolves only when all of the input promises have resolved. The resolved value is an array of the results, in the same order as the input promises. If any of the input promises reject, the main promise rejects immediately.
The Intuition
- Our
myPromiseAllfunction takes an array of promises. - It has to return a
new Promise. This is non-negotiable. - Inside the promise’s executor
(resolve, reject), we need to manage state. We need a place to store the results and a way to count how many promises have finished. - Create a
resultsarray and acompletedCountcounter, initialized to0. - Iterate through the input
promisesarray. The indexiis crucial for maintaining result order. - For each
promise, attach a.then()handler to it. - When a promise resolves with a
value, store thatvaluein ourresultsarray at the correct index (results[i] = value). This is how we maintain order. - After storing the result, increment
completedCount. - Check: is
completedCountequal to the total number of promises? If yes, we’re doneβresolvethe main promise with theresultsarray. - What about failure? We also need to handle rejection for each promise. If any promise rejects with a
reason, we must immediatelyrejectthe main promise with that samereason.
Building It
function myPromiseAll(promises) {
// It must return a new promise.
return new Promise((resolve, reject) => {
const results = [];
let completedCount = 0;
const totalPromises = promises.length;
// Handle the edge case of an empty array.
if (totalPromises === 0) {
resolve([]);
return;
}
promises.forEach((promise, index) => {
// Ensure we're dealing with an actual promise.
Promise.resolve(promise)
.then((value) => {
// Store the result at the correct index.
results[index] = value;
completedCount++;
// If all promises are done, resolve the main promise.
if (completedCount === totalPromises) {
resolve(results);
}
})
// If ANY promise rejects, we immediately reject the main promise.
.catch((reason) => {
reject(reason);
});
});
});
}
// LET'S TEST THE BEAST
const p1 = Promise.resolve(3);
const p2 = 42; // Not a promise, but our function should handle it.
const p3 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, "foo");
});
const p4_reject = new Promise((resolve, reject) => {
setTimeout(reject, 50, "I failed first");
});
myPromiseAll([p1, p2, p3])
.then((values) => {
console.log(values); // [3, 42, "foo"]
})
.catch((err) => {
console.error("This shouldn't run:", err);
});
myPromiseAll([p1, p3, p4_reject])
.then((values) => {
console.log("This won't run:", values);
})
.catch((err) => {
console.error(err); // "I failed first"
});
Building debounce
This isn’t a prototype method, but a utility function that’s essential for performance optimization.
The Problem: Some browser events fire continuously. Events like scroll, resize, or keyup on a search bar can trigger hundreds of times per second. If you attach an API call to a search input’s keyup event, you’ll send network requests for every keystroke: “s”, “se”, “sea”, “sear”, “searc”, “search”. This creates unnecessary network traffic and can overwhelm your server.
The Solution: A debounce function. It’s a higher-order function that takes your function and a delay period. It returns a new function that will only execute your original function after a specified amount of time has passed without it being called again.
Think of it this way: Debouncing is like waiting for a conversation to pause before responding. Every time there’s new input, the waiting timer resets, ensuring you only respond once the activity has settled.
The Intuition
debounceis a function that takes afuncand adelay.- It needs to hold a
timervariable in its scope. This is a perfect use case for a closure. - It returns a new function.
- When this new, returned function is called:
a. It must first clear any existing timer.
clearTimeout(timer). This resets the waiting period. b. It then sets a newsetTimeout. c. The callback for thissetTimeoutwill execute the originalfuncafter the specifieddelay.
Building It
function debounce(func, delay) {
let timer; // This variable persists because of the closure.
// Return the new, debounced function.
return function (...args) {
// `this` and `args` need to be preserved for the original function.
const context = this;
// Clear the previous timer if it exists.
clearTimeout(timer);
// Set a new timer.
timer = setTimeout(() => {
func.apply(context, args);
}, delay);
};
}
// HOW TO USE IT:
const searchInput = document.getElementById("search-bar");
function makeApiCall(query) {
console.log(`Searching for: ${query}...`);
// Imagine fetch('/api/search?q=' + query) here
}
// Wrap our API call in a debounce function.
const debouncedApiCall = debounce(makeApiCall, 500); // 500ms delay
searchInput.addEventListener("keyup", (event) => {
debouncedApiCall(event.target.value);
});
Now, if you type “search” quickly, the API call will only be made once, 500ms after you stop typing. You’ve effectively prevented unnecessary server requests and improved user experience.
Understanding Through Implementation
You’ve built .map, .filter, Promise.all, and debounce. They’re no longer mysterious black boxes. They’re understandable functions built from fundamental JS concepts. You’ve examined their internal logic and understood their implementation patterns.
This represents the mindset of a true engineer: don’t just use the library, understand how you could build the library. This deep knowledge differentiates actual devs from those who only follow tutorials. It’s what enables you to do extraordinary shit. And I am not just talking about the concept of Polyfill in particular, I am talking about this mentality. The mentality of going one step beyond where the average Joe goes till.
Your next challenge: find another method you use regularly and implement it yourself. The process of working through implementation details is where deeper understanding develops.
As discussed in the last part, the concept of classes is still left to be discussed. For that…
See you around, learner :)