AniUI Academy

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.

15 min read

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 access

Pair 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.2

Keep 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"); // false

closures 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

Try it yourself
Loading playground...

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; // SyntaxError

Instance 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 instance

Sharing 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.keys and spread see only own properties.
  • ?. short-circuits on null and undefined and beats long && chains.
  • class is 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

up to 50
  1. 1.An object user is created with Object.create(base) where base has a greet method, then given a name. What does Object.keys(user) return?

  2. 2.What does order?.customer?.city evaluate to when order.customer is undefined?

  3. 3.Where do methods defined in a class body live?

  4. 4.When is a class the right tool rather than a plain object or factory function?

4 left to answer