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.
Every plain object inherits from one shared object, Object.prototype: it's where methods like toString and hasOwnProperty come from. Prototype pollution is a security bug in which input from outside the program, such as a query string, a form field's name, or a JSON request body, gets code to write a property onto Object.prototype. From then on, every object in the program seems to have that property.
The way in is a few special key names. On a plain object, obj.__proto__ isn't an ordinary property: it's a getter and setter, inherited from Object.prototype, that reads or replaces the object's prototype. So obj["__proto__"] is Object.prototype itself, and a property written through it is written for everyone:
const settings = {};
const key = "__proto__"; // imagine it came from a query string
settings[key].isAdmin = true; // settings.__proto__ is Object.prototype
({}).isAdmin; // true: every object has it now
const user = { name: "Ada" };
user.isAdmin; // true, though user never had the property
delete Object.prototype.isAdmin; // clean up
constructor and prototype lead to the same place: obj.constructor is Object, and Object.prototype is the shared prototype. Here's where a function that sets a value at a dotted path would write, if it followed each key as given:
| Path | Following it reaches | The value lands on |
|---|---|---|
user.isAdmin | obj.user, an ordinary nested object | obj.user, as intended |
__proto__.isAdmin | obj.__proto__, which is Object.prototype | Object.prototype, so every object |
constructor.prototype.isAdmin | obj.constructor, which is Object, then Object.prototype | Object.prototype, so every object |
Why it's a security bug: code often checks a property that's normally missing, like if (user.isAdmin), and after pollution every object passes. Replacing an inherited method, such as toString, can also make code all over the app throw. Bugs like this have been found and fixed in popular libraries, Lodash and jQuery among them.
One more detail. Assigning obj["__proto__"] = value on a plain object doesn't create a key named __proto__: it replaces obj's prototype with value, or does nothing if value isn't an object or null. JSON.parse, though, does create an ordinary key named __proto__, so parsed JSON can carry one into code that copies or merges keys:
const parsed = JSON.parse('{"__proto__": {"isAdmin": true}}');
Object.keys(parsed); // ["__proto__"]: a real own key, harmless so far
const target = {};
target["__proto__"] = parsed["__proto__"]; // replaces target's prototype
Object.keys(target); // []: no key was created
target.isAdmin; // true, inherited from its new prototype
Write JSON.myParse(text), which reads a string of JSON and returns the value it describes, like JSON.parse(text), without calling JSON.parse, eval, or new Function. There's no reviver argument.
text holds exactly one value. Whitespace may come before it, after it, and between its parts, such as around : and ,. Only spaces, tabs, newlines, and carriage returns count as whitespace.
true, false, and null are written exactly so, in lowercase.
A number is an optional -, then either 0 or a digit from 1 to 9 followed by any number of digits, then optionally a . and at least one digit, then optionally an e or E, an optional + or -, and at least one digit. So -0.5e10 is a number, but 01, .5, 5., +1, NaN, and Infinity aren't. Its value is what Number returns for that text.
A string is written in double quotes. Any character can appear in it as it is, except ", \, and the control characters below U+0020 (the space), such as a raw newline or tab. A backslash starts an escape: \", \\, \/, \b, \f, \n, \r, and \t each stand for one character, and \u followed by exactly four hex digits stands for the UTF-16 code unit with that number. Any other escape is an error.
An array is values separated by commas, between [ and ]. An object is members separated by commas, between { and }, where a member is a string key, a :, and a value. Either can be empty, but every comma must sit between two items: no leading, trailing, or doubled commas.
Arrays come back as plain arrays, and objects as plain objects with their properties in the order the keys first appear. If a key appears twice, the last value wins. A key named "__proto__" becomes an ordinary own property, as it does with JSON.parse: it never changes the object's prototype.
For any text that breaks these rules, throw a SyntaxError. Its message must include at position N, where N is the index of the first character that doesn't fit, or the length of text if the text ends too soon.
JSON.myParse('{"name": "Ada", "tags": ["x", null], "age": 36.5}');
// { name: "Ada", tags: ["x", null], age: 36.5 }
JSON.myParse('"caf\\u00e9\\n"');
// "café\n": the escapes become é and a newline
JSON.myParse("[1, 2,]");
// SyntaxError: Unexpected character "]" at position 6
JSON.myParse('{"a": 1');
// SyntaxError: Unexpected end of JSON input at position 7
expect(JSON.myParse("true")).toBe(true);
expect(JSON.myParse("false")).toBe(false);
expect(JSON.myParse("null")).toBeNull();
for (const text of ["0", "-12", "3.25", "1e3", "2E-2", "-0.5e+1", "-0", "1234567890"]) {
expect(JSON.myParse(text)).toBe(Number(text));
}
expect(JSON.myParse('"hello"')).toBe("hello");
expect(JSON.myParse('""')).toBe("");
expect(JSON.myParse('"say \\"hi\\""')).toBe('say "hi"');
expect(JSON.myParse('"back\\\\slash \\/"')).toBe("back\\slash /");
expect(JSON.myParse('"\\b\\f\\n\\r\\t"')).toBe("\b\f\n\r\t");
expect(JSON.myParse('"caf\\u00e9 \\u00E9"')).toBe("café é");
expect(JSON.myParse('"\\ud83d\\ude00"')).toBe("😀");
expect(JSON.myParse('"café ✓ <tag>"')).toBe("café ✓ <tag>");
const text = ' {\n\t"name" : "Ada" ,\r\n "tags" : [ 1 , [ ] , { } , [ true , null ] ] } ';
expect(JSON.myParse(text)).toEqual({ name: "Ada", tags: [1, [], {}, [true, null]] });
expect(JSON.myParse("[]")).toEqual([]);
expect(JSON.myParse("{}")).toEqual({});
expect(JSON.myParse('[[[["deep"]]]]')).toEqual([[[["deep"]]]]);
const value = JSON.myParse('{"b": 1, "a": 2, "b": 3}');
expect(Object.getPrototypeOf(value)).toBe(Object.prototype);
expect(value).toEqual({ b: 3, a: 2 });
expect(Object.keys(value)).toEqual(["b", "a"]);
expect(Array.isArray(JSON.myParse("[1]"))).toBe(true);
const value = JSON.myParse('{"__proto__": {"admin": true}, "name": "x"}');
expect(Object.getPrototypeOf(value)).toBe(Object.prototype);
expect(value.admin).toBeUndefined();
expect(Object.keys(value)).toEqual(["__proto__", "name"]);
expect(Object.getOwnPropertyDescriptor(value, "__proto__").value).toEqual({ admin: true });
const broken = ["", " ", "[1, 2", "[1, 2,]", "[,1]", "[1,,2]", "[1 2]", "{", '{"a": 1,}', '{"a" 1}', '{"a": }', '{"a": 1 "b": 2}', "{a: 1}", "{'a': 1}", '{"a": 1}}', "[1] [2]", "{} x"];
// Lists every text that didn't throw a SyntaxError.
const missed = broken.filter((text) => {
try {
JSON.myParse(text);
return true;
} catch (error) {
return !(error instanceof SyntaxError);
}
});
expect(missed).toEqual([]);
const invalid = ["01", "-", "-a", ".5", "5.", "1.e3", "1e", "+1", "0x1F", "NaN", "Infinity", "undefined", "tru", "True", "nulll", "'text'", '"open', '"tab\there"', '"line\nbreak"', '"\\x41"', '"\\u12"', '"\\u12G4"', "\u00a01", "\v1", "\f1"];
// Lists every text that didn't throw a SyntaxError, with invisible characters shown as <U+…>.
const visible = (text) => text.replace(/[\u0000-\u001f\u00a0]/g, (char) => "<U+" + char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0") + ">");
const missed = invalid.filter((text) => {
try {
JSON.myParse(text);
return true;
} catch (error) {
return !(error instanceof SyntaxError);
}
});
expect(missed.map(visible)).toEqual([]);
expect(() => JSON.myParse("[1, 2,]")).toThrow(/at position 6\b/);
expect(() => JSON.myParse('{"a" 1}')).toThrow(/at position 5\b/);
expect(() => JSON.myParse('{"list": [1, 2 3]}')).toThrow(/at position 15\b/);
expect(() => JSON.myParse("{} x")).toThrow(/at position 3\b/);
expect(() => JSON.myParse("{'a': 1}")).toThrow(/at position 1\b/);
expect(() => JSON.myParse('{"a": 1')).toThrow(/at position 7\b/);
expect(() => JSON.myParse("")).toThrow(/at position 0\b/);
const builtIn = { parse: JSON.parse, eval: globalThis.eval, Function: globalThis.Function };
JSON.parse = function () {
throw new Error("myParse called the built-in JSON.parse");
};
globalThis.eval = function () {
throw new Error("myParse called eval");
};
globalThis.Function = function () {
throw new Error("myParse called new Function");
};
let result;
try {
result = JSON.myParse('{"a": [1, "b", null], "c": true}');
} finally {
JSON.parse = builtIn.parse;
globalThis.eval = builtIn.eval;
globalThis.Function = builtIn.Function;
}
expect(result).toEqual({ a: [1, "b", null], c: true });
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 JSON.parse. 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.