GUIDE · JavaScript
Classes & this
At a glance
- CLASS
- constructor, fields (public / #private / static), getters/setters, static methods, extends + super, instanceof, toString/Symbol.iterator
- THIS
- decided by HOW a function is CALLED: obj.m() -> obj; m() -> undefined (strict); new F() -> the new object; arrow fn -> lexical (enclosing)
this; .call/.apply/.bind set it explicitly. Callbacks losethisunless arrow or bound. - PROTO
- every object has [[Prototype]]; class methods live on C.prototype; lookup walks the chain. Object.create(proto) builds one directly.
- SAY
- prototype chainlexical thismethod borrowingcomposition over inheritanceencapsulation via #privatepolymorphism via overridemixinduck typing
The idea#
Think of a class as the pattern for a backpack. The pattern names the pockets. new packs one real backpack for one person. Ada's bag and Bo's bag follow the same pattern, and they do not share the coins in the secret pocket.
That secret pocket is a private field, #balance. Nothing outside the class can touch it. A getter is a little window: you can read balance. In strict mode, acct.balance = 999 throws a TypeError, because the window has no setter. A public field like history is the outside pocket. A static field belongs to the pattern itself, the way a counter at the factory counts how many bags were sewn.
this is which backpack you are holding. JavaScript picks it from the call, not from where the function was written. acct.deposit(50) holds Ada's bag. new Account(...) holds the brand-new bag. A plain call holds nothing, so in strict mode this is undefined and the method throws. An arrow keeps the backpack from the surrounding function. That is lexical this. .call and .apply name the bag and run now. .bind hands you a function that will keep holding it.
Methods usually live on the shared pattern, Account.prototype. Every object has an internal link, [[Prototype]]. A missing property walks the bag, the pattern, the parent pattern, Object.prototype, then null. That walk is the prototype chain. extends adds a parent. super(...) packs the parent first. An override answers with the child bag's version. That is polymorphism. instanceof asks whether a pattern sits on the chain. A class is still a function. typeof Account is 'function'.
Why it matters#
The bug you will hit is a lost backpack. Pass counter.inc into a callback, or use a regular function inside map, and the call is no longer counter.inc(). The method runs while holding nothing. An arrow callback keeps the outer bag. fn.bind(obj) pins it. An arrow field, handler = () => this.label, is born already holding the instance.
Private fields keep a balance honest. Use extends when a savings account really is an account. When you are clipping on logging, retries, or JSON, a mixin attaches one capability to any bag. That is composition. If you only care that something has an area method, check for the method. That is duck typing. There is no interface keyword.
Say prototype chain, lexical this, method borrowing, composition over inheritance, encapsulation via #private, polymorphism via override, mixin, and duck typing.
A small example#
'use strict'; class Pocket { #coins = 0; constructor(owner) { this.owner = owner; } get coins() { return this.#coins; } add(n) { this.#coins += n; return this; }} const mine = new Pocket('ada').add(5);const pinned = mine.add.bind(mine);pinned(1);new packs Ada's pocket. add returns the same pocket, so the call can chain. pinned keeps holding mine, so a later call still drops a coin in her secret pocket. mine.coins reads the window. It does not open the zipper.
What the code below is doing#
The sheet packs one Account. The constructor stores owner, deposits a truthy opening amount, and bumps a static private #count. #balance stays private. history is public. balance is a getter only. The trap: assigning it throws a TypeError in strict mode, and that write would silently do nothing in sloppy mode. nickname trims in a setter. deposit throws a RangeError unless the amount is greater than zero, records history, and returns this. withdraw returns false when the pocket is short. Static count and from sit on the factory. toString, toJSON, and [Symbol.iterator] make String, JSON.stringify, and for...of work.
Savings extends Account. super(owner) runs first, then rate is stored. addInterest deposits balance * rate. toString wraps super.toString(). instanceof is true for both classes. The links run from the instance to Savings.prototype to Account.prototype.
The this section is the four rules on a counter. counter.inc() holds the counter. Arrows inside map keep it. A regular function inside map sees undefined in strict mode. A detached inc throws. bind pins the counter. call and apply borrow the method for a stand-in object. apply takes an arguments array. An arrow field on Btn still returns 'ok' after you pull handler off.
Without class, Dog is a constructor and speak lives on Dog.prototype, so it is not an own property. Object.create sets the link directly. new creates an object, sets [[Prototype]], calls the function with that this, and returns it unless the function returns another object.
canLog and canRetry return a copy with one more behavior. The log reads the original object's id. Serializable is a class mixin: pass a base, get a subclass with ser. Static Id.gen needs no instance. A class expression can be anonymous. #s in o is a brand check, and a plain object fails it.
Shape throws when new.target is Shape itself, and area throws until Sq overrides it with s ** 2. implementsIface checks that every requested name is a function.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');A complete class#
class Account { static #count = 0; // static private static bankName = 'Obelisk'; // static public #balance = 0; // instance private history = []; // instance public field constructor(owner, opening = 0) { this.owner = owner; if (opening) this.deposit(opening); Account.#count++; } get balance() { return this.#balance; } // getter: read like a property set nickname(v) { this._nick = v.trim(); } // setter deposit(amt) { if (!(amt > 0)) throw new RangeError('amount must be positive'); this.#balance += amt; this.history.push(['deposit', amt]); return this; // chainable } withdraw(amt) { if (amt > this.#balance) return false; this.#balance -= amt; this.history.push(['withdraw', amt]); return true; } static count() { return Account.#count; } static from(json) { return new Account(json.owner, json.opening); } toString() { return `${this.owner}: ${this.#balance}`; } toJSON() { return { owner: this.owner, balance: this.#balance }; } *[Symbol.iterator]() { yield* this.history; } // makes `for (const h of acct)` work}const acct = new Account('ada', 100).deposit(50);Verified by 9 asserts
assert.equal(acct.balance, 150);assert.equal(acct.withdraw(500), false);assert.throws(() => acct.deposit(-1), RangeError);assert.equal(Account.count(), 1);assert.equal(String(acct), 'ada: 150');assert.equal(JSON.stringify(acct), '{"owner":"ada","balance":150}');assert.deepEqual([...acct], [['deposit', 100], ['deposit', 50]]);assert.throws(() => { acct.balance = 999; }, TypeError); // getter-only: TypeError in strict mode, silent no-op in sloppyassert.equal(acct.balance, 150);Inheritance: extends / super / override / instanceof#
class Savings extends Account { constructor(owner, rate) { super(owner); this.rate = rate; } addInterest() { return this.deposit(this.balance * this.rate); } toString() { return `[savings] ${super.toString()}`; }}const sv = new Savings('bo', 0.1).deposit(100).addInterest();Verified by 6 asserts
assert.equal(sv.balance, 110);assert.equal(String(sv), '[savings] bo: 110');assert.equal(sv instanceof Savings && sv instanceof Account, true);assert.equal(Object.getPrototypeOf(sv) === Savings.prototype, true);assert.equal(Object.getPrototypeOf(Savings.prototype) === Account.prototype, true);assert.equal(typeof Account, 'function'); // classes are functions underneath`this`#
the four rules
const counter = { n: 0, inc() { this.n++; return this.n; }, incLater() { return [1, 2].map(() => ++this.n); }, // arrow keeps `this` broken() { return [1].map(function () { return this; }); } // regular fn: `this` is undefined in strict};const loose = counter.inc; // detached method loses `this`const bound = counter.inc.bind(counter); // explicit bindingclass Btn { label = 'ok'; handler = () => this.label; } // arrow field = auto-bound method (React-style)const h = new Btn().handler;Verified by 8 asserts
assert.equal(counter.inc(), 1); // implicit binding: obj.m()assert.deepEqual(counter.incLater(), [2, 3]);assert.deepEqual(counter.broken(), [undefined]);assert.throws(() => loose(), TypeError);assert.equal(bound(), 4);assert.equal(counter.inc.call({ n: 10 }), 11); // call(thisArg, ...args)assert.equal(counter.inc.apply({ n: 20 }, []), 21); // apply(thisArg, argsArray)assert.equal(h(), 'ok');Prototypes without class syntax (still asked)#
function Dog(name) { this.name = name; } // constructor functionDog.prototype.speak = function () { return `${this.name} barks`; };const d = new Dog('rex');const proto = { hello() { return `hi ${this.who}`; } };const made = Object.create(proto, { who: { value: 'x', enumerable: true } });// `new` does: create {} -> set [[Prototype]] -> call fn with this -> return this (unless fn returns an object)Verified by 4 asserts
assert.equal(d.speak(), 'rex barks');assert.equal(Object.getPrototypeOf(d) === Dog.prototype, true);assert.equal(Object.hasOwn(d, 'speak'), false); // method lives on the prototypeassert.equal(made.hello(), 'hi x');Composition / mixins (what you say when asked about deep hierarchies)#
const canLog = o => ({ ...o, log(msg) { return `[${o.id}] ${msg}`; } });const canRetry = o => ({ ...o, retries: 3 });const svc = canRetry(canLog({ id: 'svc' }));const Serializable = Base => class extends Base { ser() { return JSON.stringify(this); } }; // class mixinclass Point { constructor(x) { this.x = x; } }Verified by 3 asserts
assert.equal(svc.log('up'), '[svc] up');assert.equal(svc.retries, 3);assert.equal(new (Serializable(Point))(1).ser(), '{"x":1}');Static vs instance; class expressions; private checks#
class Id { static next = 1; static gen() { return Id.next++; } }const Anon = class { x = 1; };class Secret { #s = 1; static is(o) { return #s in o; } } // brand checkVerified by 4 asserts
assert.deepEqual([Id.gen(), Id.gen()], [1, 2]);assert.equal(new Anon().x, 1);assert.equal(Secret.is(new Secret()), true);assert.equal(Secret.is({}), false);Abstract-ish / interface-ish patterns (JS has neither keyword)#
class Shape { constructor() { if (new.target === Shape) throw new TypeError('abstract'); } area() { throw new Error('not implemented'); }}class Sq extends Shape { constructor(s) { super(); this.s = s; } area() { return this.s ** 2; } }const implementsIface = (o, ...methods) => methods.every(m => typeof o[m] === 'function'); // duck typingVerified by 3 asserts
assert.throws(() => new Shape(), TypeError);assert.equal(new Sq(3).area(), 9);assert.equal(implementsIface(new Sq(1), 'area'), true);See also