A polyfill is code that adds a standard feature to an environment that doesn't have it yet, such as Promise.any or Array.prototype.flat in an older browser. It's written in plain JavaScript that the old environment can already run, and it follows the specification, the document that defines how the feature behaves, as closely as it can. Code written for new browsers then works in old ones too. The name comes from Polyfilla, a paste for filling cracks in walls: a polyfill fills the gaps in a browser.
A real polyfill checks for the feature first, as in if (!Promise.any), and defines it only when it's missing, so a browser that has the built-in keeps its own version:
if (!Array.prototype.flat) {
Array.prototype.flat = function flat(depth) {
// the steps the specification lists, written in plain JavaScript
};
}
Every array gets its methods from one shared object, Array.prototype, and every function from Function.prototype. So a method added there appears on every array or function in the program, even ones created before it was added, and inside it this is the array or function it was called on. Careful polyfills add it with Object.defineProperty rather than = as above, so that, like the built-ins, it isn't enumerable and doesn't show up in a for...in loop.
When an interviewer asks you to write a polyfill, they mean: reproduce a built-in's behaviour yourself, without calling it. It shows you know exactly what the built-in does, beyond its everyday use. The details are the point, because code written against the standard relies on them too:
| Built-in | Everyday use | Details a polyfill must match too |
|---|---|---|
Array.prototype.map | [1, 2].map((n) => n * 2) is [2, 4] | thisArg, holes, and the TypeError for a callback that isn't a function, thrown even when the array is empty |
Function.prototype.bind | A function with a fixed this | Preset arguments, and calling the result with new |
Promise.all | Waits for several promises | Plain values among them, an empty input, and an argument that isn't iterable |
Object.create | An object that inherits from the one you pass | null as the prototype, and a TypeError for one that's neither an object nor null |
JSON.stringify | Turns data into text | undefined and functions, toJSON, and a TypeError on cycles |
These tasks give your version its own name, such as Array.prototype.myMap, Object.myCreate, or JSON.myStringify, or make it a stand-alone function, such as promiseAll(iterable) or objectIs(a, b). Replacing the real method would change it for every other piece of code on the page, and leave you no built-in to compare yours with.
In production you rarely write polyfills by hand: libraries such as core-js provide them, and Babel can add the ones your target browsers need. A polyfill can only add missing objects, functions, and methods. New syntax, such as ?. or class, can't be added that way: a compiler has to rewrite it as older syntax, which is called transpiling.
An array's length counts slots, and a slot can be empty: no element at all, not even undefined. That empty slot is called a hole, and an array with holes is called sparse. Reading a hole gives undefined, the value you get for any property that doesn't exist, so it looks like an element holding undefined. Object.keys, which lists the indexes an array really has, tells the two apart:
const withHole = [1, , 3]; // nothing between the commas
const withUndefined = [1, undefined, 3];
withHole.length; // 3
withUndefined.length; // 3
withHole[1]; // undefined
withUndefined[1]; // undefined
Object.keys(withHole); // ["0", "2"]: there's no element at index 1
Object.keys(withUndefined); // ["0", "1", "2"]: there is one, and its value is undefined
Holes appear in a few ways:
| How | Example | Result |
|---|---|---|
| A gap in an array literal | [1, , 3] | A hole at index 1 |
new Array with one number | new Array(3) | Three holes, and length is 3 |
delete on an element | delete arr[1] | Index 1 becomes a hole, and length doesn't change |
| Writing past the end | arr[5] = 6 on [1, 2, 3] | Holes at indexes 3 and 4, and length is 6 |
Making length bigger | arr.length = 5 on [1, 2, 3] | Holes at indexes 3 and 4 |
Consoles mark holes so you can spot them: Chrome shows [1, , 3] as [1, empty, 3], and Node as [ 1, <1 empty item>, 3 ].
Built-in methods don't agree on what to do with a hole: some skip it, some keep it in their result, and some read it as undefined, or write null in JSON. That's why every task that rebuilds one of them says exactly what to do with holes. Here's [1, , 3] going through a few:
| Call | Result | What happened to the hole |
|---|---|---|
[1, , 3].filter(() => true) | [1, 3] | Skipped: the callback isn't called for it, and the result has no hole |
[1, , 3].map((n) => n * 10) | [10, , 30] | Skipped, but kept: the result has a hole in the same place |
[1, , 3].reduce((sum, n) => sum + n) | 4 | Skipped: 1 starts the total, and the callback runs once, to add 3 |
[1, , 3].flat() | [1, 3] | Dropped |
Array.from([1, , 3]) | [1, undefined, 3] | Read as undefined, which becomes a real element |
JSON.stringify([1, , 3]) | "[1,null,3]" | Written as null |
A sort is stable when items that compare as equal keep the order they had before the sort. With a compare function, equal means it returns 0 for the pair: sorted by age, two people of the same age are equal, even though their names differ. A stable sort leaves them in the order they came in. An unstable sort may put them either way round, and which way can depend on the engine or on how long the array is.
Picture a queue at a ticket office that lets members go first. A fair clerk moves the members to the front, but keeps the members in the order they arrived, and everyone else too. That's a stable sort. An unfair one also gets the members to the front, but mixes up who was standing where.
const people = [
{ name: "Ada", age: 30 },
{ name: "Bo", age: 25 },
{ name: "Cy", age: 30 },
{ name: "Di", age: 25 },
];
people.toSorted((a, b) => a.age - b.age).map((person) => person.name);
// ["Bo", "Di", "Ada", "Cy"]: Bo stays ahead of Di, and Ada ahead of Cy
| Position | Before | After a stable sort by age | After an unstable sort, one possible result |
|---|---|---|---|
| 1 | Ada, 30 | Bo, 25 | Di, 25 |
| 2 | Bo, 25 | Di, 25 | Bo, 25 |
| 3 | Cy, 30 | Ada, 30 | Cy, 30 |
| 4 | Di, 25 | Cy, 30 | Ada, 30 |
Both results are sorted by age, and the compare function can't tell them apart. Only the stable one is predictable: you know where Bo and Di will be before you sort.
In a UI, stability is what lets one sort build on another. The list above started in name order, and after the stable sort by age, the people of each age are still in name order. A sortable table works the same way: sort it by name, then click the Age header, and people of the same age stay in name order. With an unstable sort, each click could scramble the rows that tie, even when nothing in the data changed.
Since ES2019, the specification requires Array.prototype.sort to be stable, so every current engine sorts stably, and toSorted, added in ES2023, is stable too. Before that, engines could choose: until Chrome 70, in 2018, Chrome sorted arrays of more than 10 elements with an unstable sort. A polyfill, or any sort you write yourself, has to be stable on purpose.
Sorting algorithms differ here. Merge sort and insertion sort are stable. Quicksort, selection sort, and heap sort, in their usual forms, are not: they can move an item past one it's equal to. V8, the engine in Chrome and Node, sorts with TimSort, a stable mix of merge sort and insertion sort.
Write Array.prototype.mySort(compareFn), which works like the built-in sort without calling it (or toSorted). It sorts the array in place and returns that same array.
With compareFn, compareFn(a, b) decides the order of two elements: a negative result puts a first, a positive one puts b first, and 0 (or NaN) means they're equal.
Without compareFn, elements are converted to strings and compared the way < compares strings, by UTF-16 code units. So 10 comes before 9, and "B" before "a".
The sort is stable: elements that are equal keep the order they had.
undefined and holes are never passed to compareFn. After the sort, the other elements come first, sorted, then every undefined, then every hole. The length doesn't change.
If compareFn is given (not undefined) but isn't a function, throw a TypeError before changing anything.
const numbers = [10, 9, 1];
numbers.mySort(); // [1, 10, 9]: compared as strings
numbers.mySort((a, b) => a - b); // [1, 9, 10]
const people = [
{ name: "Ada", age: 30 },
{ name: "Bo", age: 25 },
{ name: "Cy", age: 30 },
];
people.mySort((a, b) => a.age - b.age).map((person) => person.name);
// ["Bo", "Ada", "Cy"]: Ada stays ahead of Cy
[3, undefined, , 1].mySort((a, b) => a - b);
// [1, 3, undefined, empty]
const numbers = [3, 1, 2];
const result = numbers.mySort((a, b) => a - b);
expect(numbers).toEqual([1, 2, 3]);
expect(result).toBe(numbers);
expect([3, 1, 2].mySort((a, b) => b - a)).toEqual([3, 2, 1]);
expect(["ccc", "a", "bb"].mySort((a, b) => a.length - b.length)).toEqual(["a", "bb", "ccc"]);
expect([5, 3, 8, 1, 9, 2, 7, 4, 6, 0].mySort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect([10, 9, 1, 100].mySort()).toEqual([1, 10, 100, 9]);
expect(["b", "a", "B", "A"].mySort()).toEqual(["A", "B", "a", "b"]);
expect(["é", "z", "e"].mySort()).toEqual(["e", "z", "é"]);
expect([true, null, false].mySort()).toEqual([false, null, true]);
const people = [
{ name: "Ada", age: 30 },
{ name: "Bo", age: 25 },
{ name: "Cy", age: 30 },
{ name: "Di", age: 25 },
{ name: "Ed", age: 30 },
];
people.mySort((a, b) => a.age - b.age);
expect(people.map((person) => person.name)).toEqual(["Bo", "Di", "Ada", "Cy", "Ed"]);
const items = Array.from({ length: 40 }, (_, i) => ({ group: (i * 7) % 3, i }));
const expected = [0, 1, 2].flatMap((group) => items.filter((item) => item.group === group));
items.mySort((a, b) => a.group - b.group);
expect(items).toEqual(expected);
expect(["b", "c", "a"].mySort(() => 0)).toEqual(["b", "c", "a"]);
expect([3, 1, 2, 5, 4].mySort(() => NaN)).toEqual([3, 1, 2, 5, 4]);
const compareFn = spy((a, b) => a - b);
const values = [3, undefined, 1, undefined, 2];
values.mySort(compareFn);
expect(values).toEqual([1, 2, 3, undefined, undefined]);
expect(compareFn.calls.flatMap((call) => call.args)).not.toContain(undefined);
expect(["b", undefined, "a", "undefined"].mySort()).toEqual(["a", "b", "undefined", undefined]);
const values = [3, , undefined, 1, ,];
values.mySort((a, b) => a - b);
expect(values).toHaveLength(5);
expect(values).toEqual([1, 3, undefined, , ,]);
expect(2 in values).toBe(true);
expect(3 in values).toBe(false);
expect(4 in values).toBe(false);
expect([, "b", "a"].mySort()).toEqual(["a", "b", ,]);
const values = [2, 1];
expect(() => values.mySort("desc")).toThrow(TypeError);
expect(() => values.mySort(null)).toThrow(TypeError);
expect(() => values.mySort({})).toThrow(TypeError);
expect(values).toEqual([2, 1]);
expect(values.mySort(undefined)).toEqual([1, 2]);
const builtIn = { sort: Array.prototype.sort, toSorted: Array.prototype.toSorted };
Array.prototype.sort = function () {
throw new Error("mySort called the built-in sort");
};
Array.prototype.toSorted = function () {
throw new Error("mySort called the built-in toSorted");
};
let result;
try {
result = [3, 1, 2].mySort((a, b) => a - b);
} finally {
Array.prototype.sort = builtIn.sort;
Array.prototype.toSorted = builtIn.toSorted;
}
expect(result).toEqual([1, 2, 3]);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Array.prototype.sort. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.