JavaScript Objects, Prototypes, and Classes


  • Description: Object literals, property descriptors, the prototype chain, classes, inheritance, private fields, and the built-in keyed collections (Map, Set).
  • My Notion Note ID: K2A-F4-5
  • Created: 2018-03-23
  • Updated: 2026-05-17
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Objects as Bags of Properties

An object is an unordered (well, mostly — see § 1.3) collection of key/value pairs. Keys are strings or Symbols; values are anything.

const user = {
  name: 'Yu',
  age: 30,
  greet() { return `Hi ${this.name}`; },
  ['computed-' + 'key']: 'value',
};

1.1 Shorthand

const x = 1, y = 2;
const point = { x, y };                 // { x: 1, y: 2 }
const obj = { greet() { /* … */ } };    // method shorthand

1.2 Spread and Rest

const base = { a: 1, b: 2 };
const extended = { ...base, c: 3 };          // { a:1, b:2, c:3 }
const overridden = { ...base, a: 99 };       // { a:99, b:2 } — later wins

const { a, ...rest } = extended;             // rest = { b: 2, c: 3 }

Spread is a shallow copy — nested objects share references.

1.3 Key Ordering

Modern engines order keys deterministically:

  1. Integer-like string keys ('0', '1', …) in numeric order.
  2. Other string keys in insertion order.
  3. Symbol keys in insertion order.

Don't rely on this for semantic ordering — use an array if order is part of the meaning. For numeric-style keys, use Map.

2. Property Access and Destructuring

user.name;                       // dot — only valid identifier names
user['name'];                    // bracket — any expression
user[`${prefix}name`];           // computed

user.address?.city;              // optional chaining — undefined if address is nullish
user.address?.city ?? 'unknown';  // combine with ??

delete user.age;                  // remove a property

'name' in user;                   // true
user.hasOwnProperty('name');      // true, own properties only (legacy)
Object.hasOwn(user, 'name');       // true — preferred since ES2022

Destructuring:

const { name, age = 0, address: { city } = {} } = user;
const { name: userName } = user;             // rename
const [first, , third, ...rest] = arr;        // array

Combine destructuring with rename + default:

const { port: p = 3000 } = config;

Spread vs rest in destructuring — same syntax, different position:

const [head, ...tail] = arr;
const { x, ...others } = obj;

3. Property Descriptors

Each property has a descriptor — visible via Object.getOwnPropertyDescriptor:

{
  value: …,
  writable: boolean,
  enumerable: boolean,
  configurable: boolean,
}

Plus accessor descriptors:

{
  get: function,
  set: function,
  enumerable: boolean,
  configurable: boolean,
}
const obj = {};
Object.defineProperty(obj, 'name', {
  value: 'Yu',
  writable: false,
  enumerable: false,
  configurable: false,
});
obj.name = 'X';      // silently fails (or throws in strict mode)

Getters / setters in object literals:

const temp = {
  _celsius: 0,
  get fahrenheit()       { return this._celsius * 9/5 + 32; },
  set fahrenheit(value)  { this._celsius = (value - 32) * 5/9; },
};
temp.fahrenheit = 212;
temp._celsius;              // 100

Locking objects:

Method Effect
Object.preventExtensions(obj) Can't add new properties.
Object.seal(obj) + Can't delete or reconfigure. Values still mutable.
Object.freeze(obj) + Can't change values. Shallow.

Use sparingly — TypeScript's readonly covers many practical use cases at compile time.

4. The Prototype Chain

Every object has an internal [[Prototype]] link to another object (or null). Property lookup walks the chain.

const animal = { eats: true };
const rabbit = Object.create(animal);
rabbit.jumps = true;

rabbit.eats;        // true — found on animal
rabbit.jumps;       // true — own
Object.getPrototypeOf(rabbit) === animal;   // true

for (const key in rabbit) console.log(key);
// jumps, eats — inherited keys included

Object.keys(rabbit);                          // ['jumps'] — own keys only
rabbit.hasOwnProperty('eats');                // false
Object.hasOwn(rabbit, 'eats');                 // false

Object.create(proto, props?) builds an object with the given prototype.

__proto__ (legacy accessor): same as [[Prototype]]. Avoid — use Object.getPrototypeOf / Object.setPrototypeOf. Changing a prototype after construction is slow; declare via class or Object.create.

4.1 Constructor Functions (Pre-Class Pattern)

function User(name) {
  this.name = name;
}
User.prototype.greet = function () { return `Hi ${this.name}`; };

const u = new User('Yu');
u.greet();                          // 'Hi Yu'
Object.getPrototypeOf(u) === User.prototype;   // true

new does four things:

  1. Creates a fresh object.
  2. Sets its [[Prototype]] to Fn.prototype.
  3. Calls Fn with this bound to the new object.
  4. Returns the new object (unless Fn returns its own object).

class syntax is sugar over this. Use class.

4.2 instanceof

u instanceof User;          // true
u instanceof Object;        // true — Object.prototype is on the chain

Walks the prototype chain looking for the right-hand side's .prototype. Configurable via Symbol.hasInstance.

5. Classes

class User {
  // Fields (instance properties initialised on construction)
  active = true;
  loginCount = 0;

  // Static fields (on the class itself)
  static defaultRole = 'guest';

  // Constructor
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  // Methods
  greet() { return `Hi ${this.name}`; }

  // Static methods
  static create(data) { return new User(data.name, data.age); }

  // Getter / setter
  get isAdult() { return this.age >= 18; }
  set ageStr(s) { this.age = Number(s); }
}

const u = new User('Yu', 30);
u.greet();
u.isAdult;
User.create({ name: 'Yu', age: 30 });
User.defaultRole;

Class bodies are strict-mode by default. Methods are non-enumerable. Class declarations are not hoisted in the way function declarations are — TDZ applies.

Class expressions:

const User = class {
  greet() { /* ... */ }
};

Useful for factories or returning anonymous classes from functions.

6. Inheritance and super

class Admin extends User {
  constructor(name, age, perms) {
    super(name, age);            // must come before using this
    this.perms = perms;
  }

  greet() {
    return `${super.greet()} [admin]`;
  }

  static create(data) {
    const admin = super.create(data);
    admin.perms = data.perms ?? [];
    return admin;
  }
}

Rules:

  • super(...) calls the parent constructor. Must be called before this in a subclass constructor.
  • super.method(...) calls the parent method, with this still pointing at the subclass instance.
  • A class can extend any constructor — including Array, Error, Map, native built-ins.
  • Multiple inheritance isn't supported; mixins do the job via functions returning class expressions.

6.1 Mixins

const Serializable = (Base) => class extends Base {
  toJSON() { return JSON.stringify(this); }
};

const Loggable = (Base) => class extends Base {
  log() { console.log(this.toJSON ? this.toJSON() : this); }
};

class Doc extends Loggable(Serializable(Object)) {}

Mixins are functions that take a base class and return a subclass. Composable, less ceremony than the diamond-problem-prone multiple inheritance.

7. Private Fields and Methods

class Counter {
  #count = 0;                       // private field
  #log() { console.log(this.#count); }   // private method

  increment() {
    this.#count++;
    this.#log();
  }
}

const c = new Counter();
c.increment();
c.#count;                            // SyntaxError — really private

The # prefix is a true language-level private — not a convention. They're per-class (subclasses don't see them), hard-private (can't be reflected at runtime), and lexically scoped to the class body.

Pre-private-field codebases used _underscore as a convention. New code should use #.

Static private:

class Tracker {
  static #count = 0;
  static increment() { Tracker.#count++; }
  static get count() { return Tracker.#count; }
}

8. Built-In Keyed Collections

8.1 Map

const m = new Map();
m.set('a', 1);
m.set(obj, 'value');      // any value as key — including objects
m.get('a');                // 1
m.has('a');                // true
m.delete('a');
m.size;                    // 1
m.clear();

for (const [k, v] of m) { /* … */ }
[...m.entries()];          // [[k, v], ...]
[...m.keys()];
[...m.values()];

new Map([['a', 1], ['b', 2]]);    // initialise from pairs
new Map(Object.entries(obj));      // from object
Object.fromEntries(m);             // back to object (string/symbol keys only)

Map vs plain object:

Concern Object Map
Key types string / symbol only anything
Size lookup manual (Object.keys().length) .size
Prototype collisions (toString, constructor) risk none
Order spec'd (integer keys first) insertion order, always
Iteration for…in, Object.keys for…of directly
Performance optimised hidden-class paths if shape stable optimised general-purpose hash

Use Map when keys aren't strings, when you need ordered iteration, or when you're using the object purely as a dictionary.

8.2 Set

const s = new Set([1, 2, 3]);
s.add(4);
s.has(2);
s.delete(2);
s.size;
[...s];                     // [1, 3, 4]

// Dedupe an array
[...new Set(arr)];

// ES2025 set algebra
s.union(other);
s.intersection(other);
s.difference(other);
s.symmetricDifference(other);
s.isSubsetOf(other);
s.isSupersetOf(other);
s.isDisjointFrom(other);

Set algebra methods land in 2024-2025 browsers; check support if you target older runtimes.

8.3 WeakMap / WeakSet

Keys (Maps) or values (Sets) are held weakly — they don't prevent garbage collection. Not enumerable.

const cache = new WeakMap();
function annotate(obj, data) {
  cache.set(obj, data);
}
// When obj is no longer referenced anywhere else, the cache entry vanishes.

Use cases: caches keyed on objects, private state for foreign objects (rare in modern code now that we have # fields).

9. References