Skip to content
← Journal
13 min readAta Mohammadi

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.

  • prototype is 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 on Object.prototype that 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 function

The 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:

  1. Check obj's own properties. Found? Return the value, or invoke the getter.
  2. Not found? Follow [[Prototype]] to the next object.
  3. Repeat.
  4. Stop when [[Prototype]] is null. Return undefined.

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…in over an instance does not surface them.
  • The whole class body is strict mode, always.
  • The constructor carries the internal slot [[IsClassConstructor]]: true.
  • super works, 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:

  1. Allocate a new ordinary object.
  2. Link its [[Prototype]] to Fn.prototype — falling back to Object.prototype if that is not an object.
  3. Call Fn with this bound to the new object.
  4. 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()); // true

A 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);
true

Every 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:

  1. Reject the keys. if (key === "__proto__" || key === "constructor" || key === "prototype") continue; in every merge and path-setter. Cheap and total for this vector.
  2. Object.create(null) for anything keyed by untrusted input — no inherited setter to reach.
  3. Map instead of an object. Keys are values, not property names; there is no chain to pollute.
  4. Object.freeze(Object.prototype) at bootstrap. A blunt instrument that breaks some libraries, but it converts a silent compromise into a loud TypeError.

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 with for…of, spread and destructuring.
  • Symbol.asyncIterator — the same for for await…of.
  • Symbol.toPrimitive — takes over coercion (see part seven of this series).
  • Symbol.toStringTag — what Object.prototype.toString.call(x) reports.
  • Symbol.hasInstance — takes over instanceof.
  • 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.keys and for…in, but Object.getOwnPropertySymbols and Reflect.ownKeys list them, and structuredClone and devtools will show them.
  • #private fields 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 it

That 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 → false

Two 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); // true

Useful, 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.
  • class is sugar over that pointer plus some slots.
  • Pollution is that pointer being writable through a setter.
  • Proxy is 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.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.