Senior Frontend Interview Prep, Part 5: The Object Model, Prototypes and Metaprogramming
Fifteen questions on what an object really is: prototype chains, property descriptors, what `class` desugars to, prototype pollution with a working exploit, and why `Proxy` and private fields do not get along.
Prototypes are the part of JavaScript everyone learns once, uses through a framework for five years, and then has to re-explain from first principles in an interview. This instalment goes through the object model as the specification defines it — including the security question that turns up in nearly every senior loop now, with a working exploit and the three mitigations.
The chain
61. prototype versus __proto__
Two different things with unhelpfully similar names.
prototypeis an ordinary property that lives on constructor functions and classes. It holds the object that instances will inherit from. It says nothing about the function's own inheritance.__proto__is an accessor defined onObject.prototypethat exposes any object's internal[[Prototype]]slot — the actual link used for lookups.
function Engine() {}
const engine = new Engine();
console.log(engine.__proto__ === Engine.prototype); // true
console.log(Engine.__proto__ === Function.prototype); // true — Engine is itself a functionThe modern spellings are Object.getPrototypeOf(obj) and Object.setPrototypeOf(obj, proto). __proto__ is normative only in Annex B — kept because the web depends on it — and, as question 68 shows, its existence as a settable accessor is the entire basis of one vulnerability class.
62. How does lookup traverse the chain?
obj.prop runs the internal [[Get]] operation:
- Check
obj's own properties. Found? Return the value, or invoke the getter. - Not found? Follow
[[Prototype]]to the next object. - Repeat.
- Stop when
[[Prototype]]isnull. Returnundefined.
Two consequences engineers rely on without noticing. A miss costs the whole chain — which is why reading a non-existent property in a hot loop is slower than reading an existing one, and why HOLEY arrays from part one of this series are expensive: every hole is a miss that has to consult Array.prototype.
And writes do not traverse. obj.prop = 1 creates an own property that shadows the inherited one, rather than modifying the prototype — unless the prototype has a setter for that name, in which case the setter runs. That asymmetry is the mechanism behind prototype pollution.
63. Object.create(proto), and why Object.create(null) is different
Object.create(proto) allocates an object and sets its [[Prototype]] to proto. An object literal {} is Object.create(Object.prototype) with extra steps.
Object.create(null) produces an object with no prototype at all:
const dict = Object.create(null);
dict.constructor = "totally fine";
dict.__proto__ = "just a string here";
console.log(Object.getPrototypeOf(dict)); // null
console.log(typeof dict.hasOwnProperty); // "undefined"
console.log(dict.__proto__); // "just a string here"No toString, no hasOwnProperty, no __proto__ accessor. For a hash map keyed by untrusted strings that is exactly right: there are no inherited names to collide with and no setter to hijack. The cost is that you must use Object.hasOwn(dict, key) and Object.keys(dict) rather than methods on the object, and that logging it in some tools looks odd.
new Map() is usually the better answer in application code. Object.create(null) is the answer when the thing genuinely must be a plain object — a JSON-shaped config, a lookup table you will spread.
64. Property descriptors
Every property is a record. It is either a data descriptor or an accessor descriptor, never both:
| Data descriptor | Accessor descriptor | |
|---|---|---|
| Holds | value |
get and/or set |
| Mutability | writable |
implied by whether set exists |
| Shared | enumerable, configurable |
enumerable, configurable |
configurable: false is the strong one. It means the property cannot be deleted, cannot be converted between data and accessor, and its attributes cannot be changed — with exactly one exception: writable may still be flipped from true to false, because that is a one-way tightening.
The gap worth knowing: properties created by assignment default to { writable: true, enumerable: true, configurable: true }, while properties created by Object.defineProperty default to false for all three. Same syntax-level result, completely different object.
65. preventExtensions, seal, freeze
| Add properties | Delete properties | Change values | Reconfigure | |
|---|---|---|---|---|
Object.preventExtensions |
No | Yes | Yes | Yes |
Object.seal |
No | No | Yes | No |
Object.freeze |
No | No | No | No |
Each is the previous one plus a restriction. All three are shallow: Object.freeze(config) does nothing to config.nested, which is the bug people ship when they think they have made something immutable.
Two footnotes that separate a good answer from a complete one. In strict mode — so in every module and every class body — writing to a frozen property throws a TypeError rather than failing silently. And freezing an object whose prototype chain matters is a real security measure: Object.freeze(Object.prototype) at boot is one of the mitigations in question 68.
66. What does class desugar to, and why can't you call one without new?
A class is a constructor function plus a prototype object holding the methods, exactly as you would have written it by hand in 2014 — with four differences that you could not have written by hand:
- Methods on the prototype are non-enumerable, so
for…inover an instance does not surface them. - The whole class body is strict mode, always.
- The constructor carries the internal slot
[[IsClassConstructor]]: true. superworks, via the[[HomeObject]]slot on methods, which is not reproducible in userland.
That third bullet answers the question. The internal [[Call]] operation checks the slot and throws:
TypeError: Class constructor Foo cannot be invoked without 'new'The reason is derived classes: super() is what actually allocates this in a subclass constructor, so a class constructor invoked without [[Construct]] would have no coherent this to work with. Forbidding the call is simpler than defining what it would mean.
67. What does new do?
Four steps:
- Allocate a new ordinary object.
- Link its
[[Prototype]]toFn.prototype— falling back toObject.prototypeif that is not an object. - Call
Fnwiththisbound to the new object. - Resolve the return: if the constructor returned an object, that object wins; otherwise the allocated object is returned. A returned primitive is ignored.
Step 4 is the interesting one and the basis of a classic trick question:
function Cache() {
if (Cache.instance) return Cache.instance; // an object → overrides `this`
Cache.instance = this;
}
console.log(new Cache() === new Cache()); // trueA singleton implemented entirely by step 4. Also the reason a constructor that ends with return undefined behaves differently from one that ends with return {}.
68. Prototype pollution
This one turns up in senior interviews far more than it used to, because it keeps turning up in CVEs.
The prerequisites: a recursive merge or a path-setter (set(obj, "a.b.c", value)), and attacker-controlled keys. The payload uses __proto__ as a key, and because writes consult setters on the prototype chain, assigning to it walks straight up into Object.prototype.
function unsafeMerge(target, source) {
for (const key in source) {
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = unsafeMerge(target[key] ?? {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
unsafeMerge({}, payload);
console.log({}.isAdmin);trueEvery object in the realm — including ones created before the attack — now reports isAdmin: true. Any later if (user.isAdmin) on an object that does not define the key itself is now true. It is a one-line privilege escalation, and it needs no eval, no injection into a template, and no network access beyond the JSON you already accepted.
Note the detail that makes it work: JSON.parse creates __proto__ as an own data property on the parsed object, so the for…in loop sees it. It is the write into target that triggers the accessor.
Four mitigations, in order of how much they actually buy you:
- Reject the keys.
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;in every merge and path-setter. Cheap and total for this vector. Object.create(null)for anything keyed by untrusted input — no inherited setter to reach.Mapinstead of an object. Keys are values, not property names; there is no chain to pollute.Object.freeze(Object.prototype)at bootstrap. A blunt instrument that breaks some libraries, but it converts a silent compromise into a loudTypeError.
If you are asked this and you get to the mitigations and mention that the write-side asymmetry is the root cause, you have answered it better than most.
69. How does Proxy intercept operations?
A Proxy sits in front of a target and replaces its internal methods — [[Get]], [[Set]], [[HasProperty]], [[Delete]], [[OwnPropertyKeys]], [[Apply]], [[Construct]] and the rest — with your handler functions, called traps.
const target = { id: 101, secret: "classified" };
const audited = new Proxy(target, {
get(object, property, receiver) {
console.log(`read: ${String(property)}`);
return Reflect.get(object, property, receiver);
},
});This is the machinery behind Vue's reactivity, Immer's drafts, MobX, ORM lazy-loading and every mock library that records calls. It is also not free: a proxied property access is several times the cost of a direct one and can never be inline-cached, which is why reactive frameworks are careful about what they wrap.
Proxies also have invariants they cannot violate: a get trap must return the real value for a non-configurable, non-writable property, and ownKeys must report every non-configurable own key. Break one and the engine throws.
70. Why use Reflect inside traps?
Reflect methods correspond one-to-one with the internal methods a trap replaces, which matters for two concrete reasons.
Receiver forwarding. Reflect.get(target, prop, receiver) invokes an inherited getter with this set to the receiver — the proxy — rather than the raw target. Without it, a getter that reads another property would read through the target and bypass your trap entirely, which in a reactive system means a silently missing dependency.
Correct return values. A set trap must return a boolean saying whether the write succeeded. Reflect.set(...) returns exactly that. target[prop] = value returns the value and throws in strict mode when the property is not writable — the wrong shape and the wrong failure mode.
The rule to state: every trap should end in the corresponding Reflect call, with the receiver threaded through.
71. Well-known symbols and Symbol.species
Well-known symbols are the language's extension points — keys whose presence changes how built-in operations behave:
Symbol.iterator— makes an object work withfor…of, spread and destructuring.Symbol.asyncIterator— the same forfor await…of.Symbol.toPrimitive— takes over coercion (see part seven of this series).Symbol.toStringTag— whatObject.prototype.toString.call(x)reports.Symbol.hasInstance— takes overinstanceof.Symbol.species— which constructor derived instances should use.
Symbol.species answers: when you .map() over a subclass of Array, do you get the subclass back or a plain Array? By default, the subclass. Override the species getter to return Array and you get plain arrays — which is what you want when your subclass has invariants (a sorted list, a fixed length) that a mapped result would not satisfy.
It is also a cautionary tale worth mentioning: species is widely considered a design mistake, it has been a source of security bugs in engines, and TC39 has discussed removing it. Knowing that is a stronger signal than knowing the API.
72. Private fields versus symbol properties
- Symbol keys give you privacy by obscurity. They are skipped by
Object.keysandfor…in, butObject.getOwnPropertySymbolsandReflect.ownKeyslist them, andstructuredCloneand devtools will show them. #privatefields give you real encapsulation. They are not properties at all; they live in a per-instance private slot keyed by the class, and no reflection API can reach them.
class Account {
#balance = 10;
static has(object) {
return #balance in object; // the brand check
}
peek() {
return this.#balance;
}
}
const account = new Account();
console.log(Reflect.ownKeys(account)); // []
console.log(Account.has({})); // false
const proxied = new Proxy(account, {});
proxied.peek();[]
false
TypeError: Cannot read private member #balance from an object whose class did not declare itThat last line is the detail to have ready. A Proxy cannot forward private field access, because this inside the method is the proxy, and the proxy is not an instance of the class that declared the field. Any library that wraps your objects in proxies — a reactivity system, a deep-freeze helper, a mock — will break on private fields, and the fix is to bind methods to the target rather than the proxy in the get trap.
The #field in object form is also worth knowing on its own: it is the only sanctioned way to test for a brand without a try/catch.
73. How does instanceof work?
a instanceof B
1. If B[Symbol.hasInstance] exists → return B[Symbol.hasInstance](a)
2. Otherwise read B.prototype
3. Walk a's prototype chain, comparing each link to B.prototype
4. Match → true; chain reaches null → falseTwo things fall out. instanceof is about prototype identity, so it fails across realms: an array from an iframe is not instanceof your Array, because it inherits from the iframe's Array.prototype. Array.isArray() exists precisely to answer that question properly.
And Symbol.hasInstance lets you redefine membership structurally:
class Serialisable {
static [Symbol.hasInstance](value) {
return typeof value?.toJSON === "function";
}
}
console.log(new Date() instanceof Serialisable); // trueUseful, and exactly the kind of thing to use sparingly — an instanceof that lies is a debugging experience nobody enjoys.
74. structuredClone versus JSON.parse(JSON.stringify(x))
The JSON round-trip is a lossy deep copy that everyone has shipped and everyone has regretted. structuredClone() is the real algorithm — the same one that moves data between a page and a worker.
| JSON round-trip | structuredClone |
|
|---|---|---|
| Circular references | Throws | Preserved |
Date |
Becomes a string | Stays a Date |
Map, Set |
Becomes {} |
Preserved |
ArrayBuffer, typed arrays |
Becomes {} or an index object |
Preserved |
RegExp, BigInt, Blob, File, ImageData |
Lost | Preserved |
undefined in an object |
Key dropped | Preserved |
| Functions | Silently dropped | Throws DataCloneError |
DOM nodes, WeakMap |
Dropped / broken | Throws |
| Prototypes | Lost | Lost (a cloned class instance is a plain object) |
| Getters | Flattened to values | Flattened to values |
Note the last two rows: structuredClone is not a magic copy. It preserves data, not identity or behaviour. A cloned instance of your class is a plain object with the same fields.
And "throws instead of silently dropping" is a feature. The JSON round-trip's willingness to quietly discard a function is how stale state ends up in a store.
75. How do getters and setters affect inline caches?
Part one of this series covered inline caches on data properties: check the map, load from a fixed offset. An accessor cannot do that — the value is not at an offset, it is the result of calling a function.
So the IC has to install a different kind of handler: check the map, then perform a full call to the getter. That is more work, it is a call the optimiser may or may not be able to inline, and crucially it is a different handler shape from a data-property load. A hot site that sees both data and accessor properties for the same name goes polymorphic on that difference alone, even when the objects look identical to you.
The practical advice: keep getters trivial so they can be inlined, and do not mix "this field is a value on some objects and a getter on others" if the access site is hot. Class fields versus prototype accessors is exactly that mix.
The thread through all of it
An object in JavaScript is a map of keys to descriptors, plus one pointer to another object. Everything above is a consequence:
- Inheritance is that pointer.
classis sugar over that pointer plus some slots.- Pollution is that pointer being writable through a setter.
Proxyis replacing the operations that follow that pointer.- Private fields are the one thing that is deliberately not in the map.
Answer from that model rather than from a list of APIs, and the follow-up questions stop being hard.
Next: asynchronous concurrency — promise internals, cancellation, workers, SharedArrayBuffer and Atomics.