Lesson 19 of 28
Objects, Classes and Prototypes
Optional chaining, getters, and the prototype chain shown plainly with Object.create — so class finally reads as syntax over objects rather than a separate system.
Two things break in real code here. Reading order.customer.city on an API
response where customer is missing, and spreading an object only to discover
its methods disappeared. Both come from not knowing where a property actually
lives.
Literals do more than you remember
Shorthand when the key matches the variable, computed keys when the name is decided at runtime:
const title = "Prototypes";
const field = "minutes";
const lesson = {
title, // same as title: title
[field]: 15, // key comes from the variable
["is" + "Draft"]: true,
describe() { // method shorthand
return this.title;
},
};Computed keys matter most when you are turning a list into a lookup, or building a form state object keyed by field name.
Reading objects without crashing
Three functions cover almost all iteration over an object:
const progress = { closures: 100, prototypes: 40 };
Object.keys(progress); // ["closures", "prototypes"]
Object.values(progress); // [100, 40]
Object.entries(progress); // [["closures", 100], ["prototypes", 40]]
for (const [slug, percent] of Object.entries(progress)) {
console.log(slug, percent);
}Object.assign(target, ...sources) copies own enumerable properties into the
target and returns it. It mutates the first argument, which is why spread is
usually the better choice for merging — that is the next lesson.
Then optional chaining. Before it, defensive reads looked like this:
const city = order && order.customer && order.customer.address &&
order.customer.address.city;That chain has a subtle flaw as well as being ugly: if any link is 0 or ""
you get that value back instead of undefined. Optional chaining checks only
for null and undefined.
const city = order?.customer?.address?.city; // undefined if any link is missing
order.notify?.(); // only calls if notify exists
order.tags?.[0]; // safe index accessPair it with ?? to supply a fallback, and use ?? rather than || so a
legitimate 0 survives:
const quantity = order?.quantity ?? 1;Getters and setters
A getter is a function that reads like a property. Use it for derived values.
const cart = {
items: [{ price: 199 }, { price: 499 }],
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
},
set coupon(code) {
this.discount = code === "LAUNCH" ? 0.2 : 0;
},
};
cart.total; // 698 — no parentheses
cart.coupon = "LAUNCH";
cart.discount; // 0.2Keep getters cheap and free of side effects. Nobody expects reading a property to make a network request.
The prototype chain
Here is the model everything else rests on. Every object has a hidden link to
another object. When you read a property that the object does not have,
JavaScript follows that link and looks there, then follows the next link, until
it finds the property or runs out and returns undefined.
You can build it by hand:
const lessonBase = {
describe() {
return `${this.title} (${this.minutes} min)`;
},
};
const closures = Object.create(lessonBase);
closures.title = "Closures";
closures.minutes = 13;
closures.describe(); // "Closures (13 min)"
Object.keys(closures); // ["title", "minutes"] — describe is not own
closures.hasOwnProperty("describe"); // falseclosures has no describe of its own. The lookup walks up to lessonBase
and finds it there. Crucially, this inside describe is still closures,
because this comes from the call site — the object before the dot.
This is also why spreading loses methods. { ...closures } copies own
properties only, so the new object has title and minutes and no describe.
Same for JSON.stringify.
Try it yourself
class is syntax over exactly that
Now class should look familiar. It builds the same two-object arrangement:
one object holding the shared methods, and instances linked to it.
class Lesson {
published = false; // instance field
constructor(title, minutes) {
this.title = title;
this.minutes = minutes;
}
describe() {
return `${this.title} (${this.minutes} min)`;
}
static fromApi(data) {
return new Lesson(data.title, data.duration_minutes);
}
}
const lesson = Lesson.fromApi({ title: "Modules", duration_minutes: 11 });Fields are assigned per instance. Methods go on Lesson.prototype and are
shared. static members belong to the class itself, not to instances, and are
the natural home for alternative constructors like fromApi.
Inheritance links one prototype to another:
class Quiz extends Lesson {
constructor(title, minutes, questions) {
super(title, minutes);
this.questions = questions;
}
describe() {
return `${super.describe()} — ${this.questions.length} questions`;
}
}super(...) runs the parent constructor and must come before you touch this.
super.describe() calls the parent version of an overridden method.
Private fields start with a hash and are genuinely inaccessible from outside — not a naming convention, an error:
class Account {
#balance = 0;
deposit(amount) {
this.#balance += amount;
return this.#balance;
}
}
new Account().#balance; // SyntaxErrorInstance methods versus prototype methods
A method written in the class body exists once. A class field holding an arrow function exists once per instance.
class Lesson {
describe() {
return this.title;
}
summarise = () => this.title; // field, not a prototype method
}
const a = new Lesson();
const b = new Lesson();
a.describe === b.describe; // true — one shared function
a.summarise === b.summarise; // false — one per instanceSharing is the point of the prototype: a thousand lessons still need only one
describe. The arrow field trades that for a permanently bound this, which
is why it is the standard fix for callbacks. Use fields deliberately, not by
default.
When not to use a class
Most JavaScript should not reach for class. If your object is data from an
API, keep it a plain object and write functions that take it as an argument —
that stays easy to serialise, log, compare and test. If you want private state
and a small interface, a factory function with a closure does the job with less
ceremony and no this to lose.
Classes earn their place when you create many instances that each hold mutable state and behaviour, when a base type genuinely has variants, or when a library hands you one and expects you to extend it. Everything else is usually a plain object.
Compare the two shapes for the same job:
// class
class Cart {
items = [];
add(item) {
this.items.push(item);
}
}
// factory with a closure
function createCart() {
const items = [];
return {
add: (item) => items.push(item),
count: () => items.length,
};
}The factory has no this to lose when a method is passed as a callback, and
items is truly private without new syntax. The class is more familiar and
cheaper at scale. Neither is wrong; the factory is the one people forget to
consider.
What to remember
- A property lookup walks the prototype chain;
Object.keysand spread see only own properties. ?.short-circuits onnullandundefinedand beats long&&chains.classis a readable way to build a prototype link — methods on the prototype, fields on the instance.- Reach for plain objects and functions first; use a class when instances carry both state and behaviour.
Check yourself
4 questions · pass 3/4 to unlock Destructuring and Spread
1.An object
useris created withObject.create(base)wherebasehas agreetmethod, then given aname. What doesObject.keys(user)return?2.What does
order?.customer?.cityevaluate to whenorder.customerisundefined?3.Where do methods defined in a
classbody live?4.When is a
classthe right tool rather than a plain object or factory function?
4 left to answer