Skip to main content

Objects and prototypes

Read the base course material first

This lecture assumes you already know how to write an object literal, a constructor function and a class. That ground is covered by Andres Käver's base course lecture on JS objects and inheritance — read it before this page if any of it is hazy.

What follows starts one level below: not how to write an object, but what the engine is actually doing when you read a key off one.


What a property actually is​

1.1 A property is a description, not a value​

An object is not a bag of values. It is a set of keys, each mapping to a property descriptor — a small record describing how that key behaves.

const a = { x: 1 };
Object.getOwnPropertyDescriptor(a, "x");
Output
{ value: 1, writable: true, enumerable: true, configurable: true }

Four fields, not one. That is the real shape of { x: 1 }.

There are two kinds of descriptor and a key is always exactly one of them:

KindFieldsWhat reading does
Data propertyvalue, writablehands back value
Accessor propertyget, setcalls a function

Both also carry enumerable (does it show up in Object.keys and for...in) and configurable (may it be deleted or redefined).

Reading a key whose descriptor has no getter is not an error — the specification says the result is undefined, the same as reading a key that does not exist. A property that silently swallows reads is a strange thing to build, and building one by accident is easy.

Object literals and defineProperty do not agree
const b = {};
Object.defineProperty(b, "x", { value: 1 });
Object.getOwnPropertyDescriptor(b, "x");
// { value: 1, writable: false, enumerable: false, configurable: false }

A literal gives you true for all three flags. defineProperty defaults every omitted flag to false. Same value, invisible and immutable, and no error to tell you.

1.2 Which keys you see depends on how you ask​

const proto = { inherited: 1 };
const o = Object.create(proto);
o.own = 2;
Object.defineProperty(o, "hidden", { value: 3, enumerable: false });

console.log(Object.keys(o));
const seen = [];
for (const k in o) seen.push(k);
console.log(seen);
console.log(Object.getOwnPropertyNames(o));
Output
[ 'own' ]
[ 'own', 'inherited' ]
[ 'own', 'hidden' ]

Three ways of asking, three different answers, from one object.

Own onlyIncludes inheritedIncludes non-enumerable
Object.keysyesnono
for...innoyesno
Object.getOwnPropertyNamesyesnoyes

for...in is the odd one, and it is why you were told to guard it with hasOwnProperty and then told to stop using it. It walks the prototype chain. Everything else on this page is about that chain.

Use Object.hasOwn

Object.hasOwn(obj, key) replaced obj.hasOwnProperty(key). The old form is a method inherited from Object.prototype, so it breaks on an object that does not have Object.prototype in its chain, or one that has a property called hasOwnProperty. Object.hasOwn asks the question from outside and cannot be interfered with.

1.3 All of it in one line​

Now the same ideas at once.

const proto = {
set x(v) {
this._x = v;
},
};
const o = Object.create(proto);
o.x = 5;
console.log(Object.keys(o), o.x);
Output
[ '_x' ] undefined

Nothing here is exotic, and you already have the vocabulary for two-thirds of it.

x on proto is an accessor property, so the assignment stored nothing — it called the setter. The setter wrote to _x, which is why Object.keys reports a key you never assigned to. And reading x back gives undefined, because that accessor has a set and no get.

The third part is the one you do not have yet. The setter lives on proto, but _x landed on o. The object holding a function and the object that function runs against are not the same thing — the chain in section 2, this in section 3.


The prototype chain​

Every object has a hidden internal link to another object, or to null. The specification calls it [[Prototype]]. You read it with Object.getPrototypeOf.

That is the whole mechanism. There is no class hierarchy underneath; there is one link, followed repeatedly.

const animal = {
speak() {
return `${this.name} makes a sound`;
},
};
const dog = Object.create(animal);
dog.name = "Rex";

dog.speak(); // "Rex makes a sound"
Object.getPrototypeOf(dog) === animal; // true

When you read dog.speak, the engine looks for an own key speak on dog, does not find one, follows the link to animal, finds it there, and calls it. dog never gained a speak property. Check with Object.keys(dog) — it is ['name'].

Note what this is inside that call. Not animal, where the function lives. dog, the object the lookup started from. Hold that thought until section 3.

2.2 Reading walks the chain. Writing does not.​

This asymmetry is the single most useful thing on the page.

const proto = { x: 1 };
const o = Object.create(proto);
o.x = 5;

console.log(Object.keys(o), o.x, proto.x);
Output
[ 'x' ] 5 1

The assignment did not modify proto. It created a new own property on o that shadows the inherited one. This is what you want almost always, and it is why you can safely assign to an object without worrying about corrupting things that share its prototype.

But "writing does not walk" is not quite true, and the exception is where 1.3 came from. An assignment does search the chain first — not to write there, but to ask what kind of property it would be writing over:

What the search findsWhat o.x = 5 does
nothingcreates an own data property on o
a writable data propertycreates an own data property on o (shadows it)
an accessor with a settercalls the setter, with this set to o. No own property is created.
an accessor with no setterfails
a non-writable data propertyfails

Row three is 1.3. The setter ran with this as o, so this._x = v created _x on o. x was never a key on o at all.

The last two rows fail differently depending on mode
const proto = {};
Object.defineProperty(proto, "x", { value: 1, writable: false });
const o = Object.create(proto);
o.x = 5;
console.log(o.x);

In sloppy mode this prints 1. The assignment did nothing and said nothing.

In strict mode — so in every module you write — it throws TypeError: Cannot assign to read only property 'x'.

This is one of the better arguments for strict mode. The silent version is a bug you find three days later.

2.3 prototype and [[Prototype]] are different things​

The naming here is genuinely bad and it is worth being precise once.

  • [[Prototype]] is the hidden link on every object. Read it with Object.getPrototypeOf.
  • .prototype is an ordinary property that only functions have. It is not that function's prototype. It is the object that will become the [[Prototype]] of instances the function creates with new.
function Dog(name) {
this.name = name;
}
Dog.prototype.speak = function () {
return `${this.name} barks`;
};

const rex = new Dog("Rex");

Object.getPrototypeOf(rex) === Dog.prototype; // true
Object.getPrototypeOf(Dog) === Function.prototype; // true — Dog's own link
rex.prototype; // undefined — instances have no such property

Dog.prototype is Rex's prototype. It is not Dog's. If the two names had been chosen ten minutes more carefully, an entire genre of tutorial would not exist.

new does four things: create an empty object, set its [[Prototype]] to the constructor's .prototype, call the constructor with this bound to that object, and return it unless the constructor returned an object of its own.

2.4 Inheritance before class​

class arrived in ES2015. The mechanism underneath it did not arrive with it — everything in 2.1 to 2.3 predates it by fifteen years. The quickest way to believe that is to build inheritance by hand, the way every library did before the keyword existed.

Two constructors, no relation between them:

function Parent(name) {
this.name = name;
}
Parent.prototype.describe = function () {
return `I am ${this.name}`;
};

function Child(name, toy) {
this.toy = toy;
}

A new Child("Rex", "ball") has no describe. Its chain is child → Child.prototype → Object.prototype, and Parent.prototype is nowhere on it. Wiring one up is two separate jobs, and they are easy to confuse:

  1. Methods — get Parent.prototype onto the chain.
  2. Fields — run Parent's constructor body against the new object.
function inherit(Child, Parent) {
Child.prototype = Object.create(Parent.prototype); // 1
Object.defineProperty(Child.prototype, "constructor", {
value: Child,
writable: true,
configurable: true,
enumerable: false,
});
return Child;
}

function Child(name, toy) {
Parent.call(this, name); // 2
this.toy = toy;
}

inherit(Child, Parent);
Child.prototype.play = function () {
return `${this.name} plays with the ${this.toy}`;
};

const rex = new Child("Rex", "ball");
rex.play(); // 'Rex plays with the ball' — own prototype
rex.describe(); // 'I am Rex' — two links up
rex instanceof Parent; // true

Object.create(Parent.prototype) makes one fresh empty object whose [[Prototype]] is Parent.prototype, and installs it as the object every new Child will link to. instanceof is satisfied because it does exactly what we just did by hand: walk rex's chain looking for Parent.prototype.

Job 2 is the half people forget. Parent.call(this, name) is super(name). Not an approximation of it — a plain function call with this passed in explicitly, which is the only way to run another constructor's body against an object you already have. The prototype link carries methods. It does not carry fields, because fields are written by constructor bodies and nobody has run Parent's.

Why constructor needs putting back

Assigning to Child.prototype discards the object JavaScript created for it, and with it the default constructor property pointing back at Child. Nothing in the language reads constructor — instanceof walks the chain and never looks at it — but plenty of code does: logging, serialisers, obj.constructor.name in an error message, the occasional new this.constructor().

The enumerable: false is the point of using defineProperty here. The obvious Child.prototype.constructor = Child works, but assignment creates an enumerable property (1.1), so constructor then shows up in a for...in over every instance forever. The real one is non-enumerable, and this is how you match it.

This is also why old code is full of for (const k in obj) if (Object.hasOwnProperty.call(obj, k)). Methods attached with Prototype.method = fn are enumerable too, so for...in over an instance walked the whole chain and handed you the methods along with the data. class bodies produce non-enumerable methods, which is one small thing it quietly fixed.

The version you will find in really old code
Child.prototype = new Parent(); // don't

It puts Parent.prototype on the chain, so it looks like it works. It also runs the parent constructor once, at definition time, with no arguments, and keeps the resulting object — name: undefined and all — as the shared prototype of every child. If Parent assigns this.items = [], that one array is now shared by every instance, and child.items.push(x) is a mutation everybody sees. It is a 1.1 and a 2.2 bug at the same time: writing through the chain into a shared object.

Object.create(Parent.prototype) exists precisely to get the link without running the constructor.

So what does class ... extends actually add, if the mechanism is the same?

  • super in any method, not just the constructor. The manual version can only reach a parent method by naming it: Parent.prototype.describe.call(this). Hardcode that in three levels of hierarchy and one rename ruins your afternoon.
  • Static inheritance. class C extends P also links the constructors themselves — Object.getPrototypeOf(C) === P — so C.someStaticOfP() works. Our inherit links only the prototypes; statics do not come along.
  • Subclassable builtins. A derived constructor does not create this at all; super() creates it, and new.target decides its prototype. That is what makes class MyError extends Error and extends Array work. Array.call(this) does not.
  • Guardrails. A class throws if you call it without new, its methods are non-enumerable, and the body is strict mode whether you asked or not.

Read class as a description of the chain, not as a different object system. When something surprising happens at runtime — a method missing, this empty, a field on the wrong object — the shape above is what is actually there.

2.5 The end of the chain​

Follow the links far enough and you reach null.

rex → Dog.prototype → Object.prototype → null

Object.prototype is where toString, valueOf and hasOwnProperty live, which is why every object appears to have them.

You can opt out:

const bare = Object.create(null);
bare.toString; // undefined
`${bare}`; // TypeError: Cannot convert object to primitive value

An object with no prototype has no inherited anything. That sounds broken and is occasionally exactly right — for a dictionary of user-supplied keys, a null prototype means a key called constructor or __proto__ cannot collide with a real method. Object.hasOwn still works on it, because it asks from outside.

__proto__ is a real footgun

__proto__ is a legacy accessor on Object.prototype that reads and writes the [[Prototype]] link. It exists only for web compatibility and the specification keeps it in an appendix of things that are standardised because deleting them would break the internet.

const evil = JSON.parse('{"__proto__": {"polluted": true}}');
Object.hasOwn(evil, "__proto__"); // true — JSON.parse defines, so it is an ordinary key

const lit = { __proto__: { fromLiteral: true } };
Object.hasOwn(lit, "__proto__"); // false — in a literal it sets the link instead
lit.fromLiteral; // true

Same six characters, two completely different meanings, depending on whether the key was defined or set.

Prototype pollution — a class of vulnerability where attacker-controlled keys reach Object.prototype and change the behaviour of every object in the program — lives in exactly this gap. Use Object.getPrototypeOf and Object.setPrototypeOf in your own code and treat __proto__ as an attack surface, not an API.

Do not reassign a prototype at runtime

Object.setPrototypeOf works, and MDN, V8 and every performance guide will tell you not to use it on an object that already exists. Changing an object's shape after it has been used invalidates the inline caches the engine built for it, and the deoptimisation is severe — this is one of the few places where a correct line of JavaScript can make code an order of magnitude slower.

Set the chain when you build the object: Object.create(proto), or a class.


this​

Last week: a function remembers where it was written. That is its scope, fixed at creation, and nothing can change it.

this is the opposite. It is decided where it is called, freshly, every call. Same function, different this, depending on nothing but the shape of the call site.

That is the whole idea. Everything below is consequences.

3.1 Four call forms​

function show() {
return this === undefined ? "undefined" : this.tag;
}

const obj = { tag: "obj", show };

show(); // ?
obj.show(); // ?
show.call({ tag: "called" }); // ?
Output
undefined
obj
called

In a module, which is strict. In a sloppy-mode script the first line gives you globalThis instead, because sloppy mode substitutes the global object for an absent this. You write modules, so undefined is the answer that matters.

Call sitethis is
f()undefined (strict) or globalThis (sloppy)
obj.f()obj — whatever is left of the dot
f.call(x) / f.apply(x) / f.bind(x)()x
new F()the newly created object

The second row is the one to internalise. this is not part of the function. It is the part of the call site to the left of the dot, handed in as a hidden argument. obj.show() is closer to show(obj) than it looks.

3.2 The lost method​

Which means that if you take the function away from the dot, the this does not come with it.

class Counter {
count = 0;
increment() {
this.count++;
}
}

const c = new Counter();
const fn = c.increment;
fn();
Output
TypeError: Cannot read properties of undefined (reading 'count')

c.increment evaluates to the function. The c was only ever in the call site, and by the time fn() runs there is no call site to read it from. Class bodies are always strict, so this is undefined, and undefined.count throws.

You have hit this without recognising it:

button.addEventListener("click", c.increment); // broken
setTimeout(c.increment, 100); // broken
promise.then(c.increment); // broken
[1, 2].map(c.increment); // broken

Every one of those passes the function somewhere else, which is precisely the operation that drops this. The error usually surfaces far from the line that caused it, which is why this is more annoying than it sounds.

Three fixes, in ascending order of how much I like them:

button.addEventListener("click", c.increment.bind(c)); // explicit, verbose
button.addEventListener("click", () => c.increment()); // a call site, so the dot survives

The arrow works because it does not fix this at all — it creates a real call site, c.increment(), at the moment it runs.

3.3 Arrow functions do not have a this​

Not "have a lexical one". Do not have one.

An arrow function has no this binding of its own, so this inside it is resolved exactly like any other free variable: by looking outward through the scopes, until something has one. This is last week's mechanism, applied to a name you did not know was a name.

const timer = {
tag: "timer",
viaMethod() {
return [1].map(function () {
return this?.tag;
})[0];
},
viaArrow() {
return [1].map(() => this?.tag)[0];
},
};

timer.viaMethod(); // ?
timer.viaArrow(); // ?
Output
undefined
timer

The inner function gets its own this, and map calls it with none, so it is undefined. The arrow has none to get, so this resolves outward to viaArrow's, which is timer.

The consequences follow directly:

  • .call, .apply and .bind cannot change an arrow's this. There is nothing there to set. They do not throw; they are simply ignored.
  • An arrow is wrong as a method on an object literal — { tag: 'x', get: () => this.tag } resolves this to whatever surrounds the literal, which in a module is undefined.
  • An arrow is right as a callback inside a method, which is the case that used to need const self = this.
The rule, in one line

If the function needs a this from its call site, write function or a method. If it needs the this of the code around it, write an arrow. Everything else is a consequence of that.


Interception​

Sections 1 and 2 established that reading and writing a property can run code you wrote. This section is what that is for, and it is where last week's other question gets answered.

4.1 Getters and setters​

const user = {
first: "Ada",
last: "Lovelace",
get full() {
return `${this.first} ${this.last}`;
},
set full(v) {
[this.first, this.last] = v.split(" ");
},
};

user.full; // "Ada Lovelace" — computed on read
user.full = "Grace Hopper";
user.first; // "Grace"

A pair of functions wearing the costume of a field. From the outside, user.full is indistinguishable from data — same syntax, same autocomplete, same Object.keys.

That is the strength and the whole of the danger. user.full looks free and might not be. It looks total and might throw. Anywhere a library hands you an object, you cannot tell by looking whether reading a property costs nothing or issues a network request.

4.2 The limits of doing it by hand​

You can add an accessor to an existing object:

const state = { a: 1 };

for (const key of Object.keys(state)) {
let v = state[key];
Object.defineProperty(state, key, {
get() {
track(key);
return v;
},
set(nv) {
v = nv;
notify(key);
},
enumerable: true,
configurable: true,
});
}

That is a working reactivity system in nine lines, and it is roughly what Vue 2 did.

Now find the hole.

state.a = 2; // tracked
state.b = 9; // ...?
What happens to b

Nothing. It becomes an ordinary property with no accessor and nothing observes it, because the loop only ran over the keys that existed at setup time.

Object.defineProperty installs an accessor on a key. A key that does not exist yet cannot have one. Deletion has the same problem, and so does arr[5] = x on an array, since that is also a new key.

4.3 Proxy​

const state = new Proxy(
{ a: 1 },
{
get(target, key, receiver) {
track(key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const ok = Reflect.set(target, key, value, receiver);
notify(key);
return ok;
},
deleteProperty(target, key) {
const ok = Reflect.deleteProperty(target, key);
notify(key);
return ok;
},
}
);

state.a = 2; // seen
state.b = 9; // seen — a key that never existed
delete state.a; // seen

A Proxy does not wrap keys. It wraps the operation. The handler is consulted for any key, including ones invented later, so there is nothing left to work around.

Reflect is the companion. Each Reflect method performs the default version of one trap, with the same arguments, so Reflect.get(target, key, receiver) is "do what would have happened". Passing receiver through is what keeps this correct inside inherited getters — drop it and accessors on the prototype chain start seeing the raw target instead of the proxy.

4.4 What a Proxy costs​

It is not free and it is not seamless.

Every operation goes through a function call. Reads that were a single inline-cached machine instruction become a call into your handler and then into Reflect. For hot paths this matters; it is why Vue 3 tracks dependencies lazily and why you do not proxy an array you are about to sort a million times.

Objects with internal slots break.

const m = new Proxy(new Map(), {});
m.set("k", 1);
Output
TypeError: Method Map.prototype.set called on incompatible receiver #<Map>

Map stores its entries in an internal slot, not in properties, and Map.prototype.set checks that this is a real Map. The proxy is not one. Same for Set, Date, Promise, typed arrays, and any class with #private fields. Vue 3 special-cases collections for exactly this reason — a generic proxy over a Map cannot work.

Identity is not preserved. proxy !== target, so a Map keyed on the raw object will not find the proxied one, and === comparisons across the boundary fail. This is the source of most "why is my reactive object not equal to itself" confusion in Vue.


What objects cost​

Last week ended on the memory cost of closures. Objects have a cost too, and unlike closures it is one you choose explicitly, usually without noticing that a choice was offered.

5.1 The prototype is shared. Fields are not.​

class OnPrototype {
constructor(id) {
this.id = id;
}
handleClick() {
return this.id;
}
}

class AsFields {
constructor(id) {
this.id = id;
}
handleClick = () => this.id;
}

const a1 = new OnPrototype(1),
a2 = new OnPrototype(2);
const b1 = new AsFields(1),
b2 = new AsFields(2);

a1.handleClick === a2.handleClick; // ?
b1.handleClick === b2.handleClick; // ?
Output
true
false

A method declared in a class body goes on Class.prototype. One function object, for every instance ever created. A thousand instances share it.

A class field is created fresh in the constructor, per instance. A thousand instances means a thousand function objects, each with its own closure over this.

5.2 Measured​

Three handlers per instance, 200,000 instances, --expose-gc, measured with process.memoryUsage().heapUsed:

prototype methods:  32 bytes/instance
arrow fields: 264 bytes/instance

Eight times, for code that reads almost identically. Two hundred thousand is a lot of instances — but the ratio is the point, and it is a ratio you now control deliberately rather than by habit.

Reproduce it yourself

The script is here. Save it and run each variant in its own process:

node --expose-gc handler-cost.mjs prototype
node --expose-gc handler-cost.mjs fields

Run each two or three times; the readings should be identical to the byte. Four things are doing the work, and dropping any one of them turns the measurement into noise:

  • --expose-gc, and several gc() calls, not one. A single gc() scavenges young space and promotes the survivors rather than freeing the garbage. The reading drifts by megabytes between runs.
  • A warm-up loop before the baseline. Otherwise the first instances pay for lazy compilation and shape transitions, and that lands in the per-instance average.
  • A pre-sized array. new Array(N).fill(null) before the first reading, so the array's own growth is not inside the measured window. Using push instead reports 42 and 274 bytes — the extra ten in each column is the array's pointer slot plus growth slack, not the object, and it inflates the cheap side proportionally more.
  • Touching the instances after the second reading. The engine will collect an array it can prove is already dead, and you measure nothing.

A separate process per variant, because measuring the second on a heap the first has already grown tells you about the heap, not the class.

This is not an argument against class fields

An arrow field is bound this for free, which is the fix for section 3.2, and for a component you instantiate ten times the memory is irrelevant. Use them.

The point is that you should know which one you are choosing. "Arrow fields everywhere" and "methods everywhere" are both defensible; not knowing there is a difference is not.

5.3 Private fields are private​

class Account {
#balance = 0;
deposit(n) {
this.#balance += n;
return this;
}
get balance() {
return this.#balance;
}
static isAccount(o) {
return #balance in o;
}
}

const acc = new Account().deposit(10);
Object.keys(acc); // []
Object.getOwnPropertyNames(acc); // []
JSON.stringify(acc); // "{}"
Account.isAccount(acc); // true
Account.isAccount({}); // false

#balance is not a property with an awkward name. It is not a key at all — no descriptor, invisible to every reflection method, unreachable from outside the class body. Accessing acc.#balance from outside is a SyntaxError, caught before the program runs, not a runtime error.

The #balance in o form is a brand check: it asks whether an object was constructed by this class, and it is the only reliable way to ask that question. instanceof can be defeated by re-pointing a prototype; a private field cannot be forged.

The cost: they are per-instance, they do not survive structuredClone or JSON.stringify, and as noted in 4.4 they make an object unproxyable.

5.4 Copies are shallow, and spread flattens accessors​

const user = {
first: "Ada",
last: "Lovelace",
get full() {
return `${this.first} ${this.last}`;
},
};

const copy = { ...user };
copy.first = "Grace";
copy.full;
Output
Ada Lovelace

Spread does not copy the getter. It calls it, once, and stores the result as an ordinary data property. copy.full is now a string that was true at the moment of copying and is frozen there.

This is the mechanism behind a specific bug you will meet: spreading a reactive object, a class instance, or a store slice gives you something that looks right, passes a shallow equality check, and has quietly lost every accessor, every method on its prototype, and every private field.

Three related facts worth having together:

  • { ...obj } and Object.assign(target, obj) are not the same. Spread defines properties on a fresh object. Object.assign sets them on the target, which means it triggers setters that already exist there. Different mechanism, usually the same result, occasionally not.
  • Object.freeze is shallow. Object.freeze({ flags: { debug: false } }) leaves flags.debug writable.
  • structuredClone is deep, preserves Date, Map, Set and cycles, and throws a DataCloneError on functions. It also flattens getters, for the same reason spread does.

Take these 5 things from here​

  1. A property is a descriptor, not a value. It is either data or an accessor, and an accessor runs your code on a line that looks like a field.
  2. Reading walks the prototype chain. Writing creates an own property — unless the chain turns up a setter, in which case the setter runs instead.
  3. this is decided by the call site, freshly, every call. Take a method away from its dot and it loses it.
  4. An arrow has no this, so this inside it resolves outward like any other free name. That is last week's rule, not a new one.
  5. Prototype methods are shared; class fields are per-instance. Both are fine. Choosing without knowing is not.

You should now be able to​

  • Say whether reading a given key hands back a stored value or runs a function, and answer it from Object.getOwnPropertyDescriptor rather than from the shape of the code that created it.
  • Choose between Object.keys, for...in and Object.getOwnPropertyNames knowing which of own, inherited and non-enumerable keys each one reports.
  • Trace a read and a write through a prototype chain and say which object ends up holding the property — including the case where an inherited setter means no own property is created at all.
  • Distinguish .prototype from [[Prototype]], and build inheritance without class: link the prototypes with Object.create, run the parent constructor with .call, and say which of the two carries methods and which carries fields.
  • Determine this from the call site alone for each of the four call forms, explain why a method loses it when taken away from its dot, and say why an arrow function is not a fifth form but last week's rule applied unchanged.
  • Explain what a Proxy intercepts that a getter cannot, why Reflect belongs beside it and what dropping receiver breaks, and name the cases where a proxy simply does not work.
  • Decide whether a function belongs on the prototype or in a class field, and justify the choice by what is shared and what is allocated per instance rather than by preference.
  • Predict what a shallow copy loses — accessors, prototype methods, private fields — and pick between spread, Object.assign and structuredClone on that basis.

Reading​

Required

Recommended

Demos​