Classes
Mental model: A class is a readable spelling of a constructor function plus its prototype, with a few rules that make the sloppy versions impossible.
Level: intermediate · about 18 minutes
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
length() {
return Math.hypot(this.x, this.y);
}
}
const p = new Point(3, 4);
console.log(p.length()); // 5
console.log(Object.hasOwn(p, 'x'), Object.hasOwn(p, 'length')); // true false
console.log(typeof Point, Point.prototype.length === Object.getPrototypeOf(p).length);
// 'function' trueThe same object graph as the last lesson, in the syntax you will actually write.
Note typeof Point is "function", and length is not an own property of the instance. A class creates a constructor function and puts its methods on that function’s .prototype, exactly as you did by hand. The machinery is identical. What changes is that the class form closes the holes.
| Behaviour | Constructor function | Class |
|---|---|---|
Called without new | runs, corrupting this | throws a TypeError |
| Hoisting | declaration is hoisted and callable | hoisted but in the TDZ, so not usable early |
| Body mode | inherits the surrounding mode | always strict |
| Methods enumerable | yes if assigned with = | no, so for...in and spread skip them |
| Private state | closures or a naming convention | real #private members, enforced by the engine |
| Inheritance | manual prototype wiring plus Base.call(this) | extends and super |
Declarations, expressions and the TDZ
console.log(typeof hoisted()); // 'object' - function declarations hoist fully
try {
new Early(); // ReferenceError: Cannot access 'Early' before initialization
} catch (err) { console.log(err.constructor.name); }
function hoisted() { return {}; }
class Early {}
const Named = class Inner { // a class expression
static who() { return Inner.name; }
};
console.log(Named.name, Named.who()); // 'Inner' 'Inner'Function declarations are usable before their line. Classes are not.
A class declaration is hoisted, like let and const, but stays in the temporal dead zone until execution reaches it. Reading the name early is a ReferenceError, not undefined. Order your definitions top down, or use a class expression when you need to hand a class around as a value.
What can live in a class body
class Session {
id = crypto?.randomUUID?.() ?? 'local'; // public field, per instance
#token = null; // private field
static count = 0; // static field, on the class
static #secretKey = 'k'; // private static
static { // static initialisation block
Session.registry = new Map();
}
constructor(user) {
this.user = user;
Session.count += 1;
}
get isOpen() { return this.#token !== null; } // accessor
set token(value) { this.#token = value; } // accessor
open() { this.#sign(); return this; } // prototype method
#sign() { this.#token = `${this.user}:${Session.#secretKey}`; } // private method
static reset() { Session.count = 0; } // static method
}
const s = new Session('ada').open();
console.log(s.isOpen, Session.count, Session.registry instanceof Map); // true 1 true
console.log(Object.keys(s)); // ['id', 'user'] - no methods, no privates
| Member | Lives on | In Object.keys? | Notes |
|---|---|---|---|
method() {} | the prototype | no | shared by every instance, non-enumerable |
field = value | each instance | yes | initialised in order, before the constructor body runs |
static m() {} | the class itself | no | not visible on instances |
static f = v | the class itself | no | evaluated once, when the class is defined |
static { ... } | runs once at definition | n/a | for multi-step setup and private access |
get x() {} / set x(v) {} | the prototype | no | a property backed by functions |
#x / #m() | each instance, keyed privately | no | not a property at all; unreachable from outside |
class Order {
a = 1;
b = this.a + 1; // fields see earlier fields
constructor() {
console.log(this.a, this.b); // 1 2 - both already installed
this.c = this.b + 1;
}
}
console.log(new Order().c); // 3Initialisation order, made visible.
Getters and setters
An accessor looks like a property to the caller and runs a function underneath. Reach for one when a value is derived from other state, or when a write needs validating. Keep them cheap: readers expect a property access, not a database query.
class Rect {
constructor(w, h) { this.w = w; this.h = h; }
get area() { return this.w * this.h; } // read-only, derived
get ratio() { return this.w / this.h; }
set width(v) {
if (!Number.isFinite(v) || v <= 0) throw new RangeError('width must be positive');
this.w = v;
}
}
const r = new Rect(4, 2);
console.log(r.area, r.ratio); // 8 2
r.width = 10;
console.log(r.area); // 20
try { r.area = 99; } catch (e) { console.log(e.constructor.name); } // TypeError: getter only
Private members are private for real
A name starting with # is not a property. It is a private name scoped to the class body, so no string key, no bracket access, no Object.keys, no JSON.stringify and no proxy trap can reach it. Touching it from outside the class is a syntax error, caught before your code ever runs.
The old convention: a hint
class Old {
constructor() { this._token = 'abc'; }
}
const o = new Old();
o._token; // 'abc'
Object.keys(o); // ['_token']
JSON.stringify(o); // '{"_token":"abc"}'
// The underscore is a request,
// not a boundary.
The real thing: a boundary
class New {
#token = 'abc';
reveal() { return this.#token; }
}
const n = new New();
Object.keys(n); // []
JSON.stringify(n); // '{}'
n.reveal(); // 'abc'
// n.#token outside the class
// is a SyntaxError.Private fields also make instances non-forgeable: a method that touches this.#token throws a TypeError on any object that was not constructed by this class, even one with an identical shape.
class Wallet {
#balance = 0;
static isWallet(value) {
return typeof value === 'object' && value !== null && #balance in value;
}
add(n) { this.#balance += n; return this.#balance; }
}
console.log(Wallet.isWallet(new Wallet())); // true
console.log(Wallet.isWallet({ balance: 0 })); // false, a look-alike is not a Wallet
console.log(Wallet.isWallet(Object.create(Wallet.prototype))); // false, never constructed
const fake = { add: Wallet.prototype.add };
try { fake.add(5); } catch (e) { console.log(e.constructor.name); } // TypeErrorThe brand check: #field in obj asks "did my constructor make this?"
Statics, and where they live
new Point(3,4) ──[[Prototype]]──> Point.prototype ──> Object.prototype ──> null
(constructor, length,
get area, ...)
Point (the class) ──[[Prototype]]──> Function.prototype ──> Object.prototype ──> null
own props: prototype, name, length,
static fields and methods
statics are NOT on the instance chain:
instance.staticThing is always undefined
class Temp {
static ZERO_C = 273.15;
static #calls = 0;
constructor(k) { this.k = k; }
static fromCelsius(c) {
Temp.#calls += 1;
return new this(c + Temp.ZERO_C); // `this` in a static is the class
}
static get calls() { return Temp.#calls; }
}
console.log(Temp.fromCelsius(0).k); // 273.15
console.log(Temp.calls); // 1
console.log(new Temp(0).ZERO_C); // undefined - statics are not inherited by instances
A static block runs once, when the class is being defined, with this set to the class and full access to private static names. It exists for setup that needs more than one expression, or that has to touch privates.
class Registry {
static #byId = new Map();
static defaults;
static {
Registry.defaults = Object.freeze({ retries: 3 });
for (const seed of ['a', 'b']) Registry.#byId.set(seed, { id: seed });
}
static get(id) { return Registry.#byId.get(id); }
static get size() { return Registry.#byId.size; }
}
console.log(Registry.size, Registry.get('a'), Object.isFrozen(Registry.defaults));
// 2 { id: 'a' } true
Bending instanceof with Symbol.hasInstance
instanceof normally walks the prototype chain. If the right-hand side has a Symbol.hasInstance method, that method is called instead and its result is coerced to a boolean. This lets you express duck typing with familiar syntax, and it is also how you lie convincingly, so use it sparingly.
class Thenable {
static [Symbol.hasInstance](value) {
return Boolean(value) && typeof value.then === 'function';
}
}
console.log(Promise.resolve(1) instanceof Thenable); // true
console.log({ then(res) { res(1); } } instanceof Thenable); // true, never constructed
console.log({} instanceof Thenable); // false
// Even a plain object works as the right operand:
const Even = { [Symbol.hasInstance]: (n) => Number.isInteger(n) && n % 2 === 0 };
console.log(4 instanceof Even, 5 instanceof Even); // true false
class A {
#id = 1;
static check(v) { return #id in v; }
get id() { return this.#id; }
}
const real = new A();
const fake = Object.create(A.prototype);
console.log([A.check(real), A.check(fake), fake instanceof A]);Private fields are installed by the constructor, and Object.create never runs it, so fake has no #id and the brand check is false. instanceof only asks whether A.prototype is in the chain, and it is, so it happily returns true. Reading fake.id would throw a TypeError.
`typeof MyClass`- 'function'
Calling a class without `new`- TypeError, always
Method enumerability- non-enumerable, so spread and
for...inskip them `this` in a static method- the class it was called on, so
new this()respects subclasses `#x in obj`- the unforgeable brand check, no try/catch needed
Class fields vs prototype methods- a field arrow is per instance and auto-bound; a method is shared
`Symbol.hasInstance`- overrides
instanceofon the right-hand side
Classes did not add a new object model. They added guardrails to the one we had.
Try it yourself
Anatomy of a class body
class Task {
static nextId = 1;
#done = false;
title;
constructor(title) {
this.title = title;
this.id = Task.nextId++;
}
get done() { return this.#done; }
complete() { this.#done = true; return this; }
static of(title) { return new this(title); }
}
const t = Task.of('write tests').complete();
console.log(t.done, t.id, Object.keys(t));
console.log(JSON.stringify(t));
console.log(Object.getOwnPropertyNames(Task.prototype));
Add a private method, a static block that seeds a registry, and a getter that derives a value. Then check what Object.keys and JSON.stringify can see.
Brand checks versus instanceof
class Token {
#value;
constructor(v) { this.#value = v; }
static isToken(v) {
return typeof v === 'object' && v !== null && #value in v;
}
read() { return this.#value; }
}
const real = new Token('t1');
const lookAlike = { read: () => 't1' };
const hollow = Object.create(Token.prototype);
console.log(Token.isToken(real), Token.isToken(lookAlike), Token.isToken(hollow));
console.log(real instanceof Token, hollow instanceof Token);
try { hollow.read(); } catch (e) { console.log(e.constructor.name); }
Build a look-alike object with the same shape and see which check catches it. Then add a Symbol.hasInstance that accepts the look-alike.
Exercises
A counter with real privacy
Write a class Counter. The count is a private field starting at 0, or at the number passed to the constructor. increment(by = 1) adds and returns the counter so calls chain. A getter value reports the count and cannot be assigned to. A static from(n) builds one. A static isCounter(value) returns true only for objects this class actually constructed, and must not throw for null, numbers or strings. Nothing about the count may leak through Object.keys or JSON.stringify.
Temperature with validating accessors
Write a class Temperature holding a private celsius value. new Temperature(c) and the getter/setter pair celsius both reject anything that is not a finite number, or that is below absolute zero (-273.15), by throwing a RangeError. A fahrenheit getter converts out, and its setter converts in and reuses the same validation. Add a static fromFahrenheit(f) factory, and a static LIMITS object, built in a static block and frozen, with minCelsius: -273.15.
Check yourself
- What does this log?
- [['size'], '{"size":"small"}'] — A public field is an own, enumerable property of the instance, so it shows up in both. A method is defined on the prototype and is non-enumerable, so neither
Object.keysnorJSON.stringifysees it. - What happens here?
- A ReferenceError, because the binding exists but is uninitialised — Class declarations hoist the binding but leave it in the temporal dead zone until the definition is evaluated, exactly like
let. Reading it early throws a ReferenceError, which is far more useful than theundefinedyou would get fromvar. - Which statement about
#privatemembers is true? - They are not properties at all, and reading one from outside the class body is a syntax error — Private names are lexically scoped to the class body and are not string or symbol keys, so bracket access,
Object.getOwnPropertyNames,JSON.stringifyand proxy traps cannot reach them. The restriction is enforced at parse time, which is why#x in objexists as the sanctioned way to test for one. - Why is
static of(x) { return new this(x); }usually better thanreturn new Temp(x);? thisin a static method is the class it was called on, so a subclass gets an instance of itself — Static methods are inherited, and inside onethisis whichever class received the call.new this(x)therefore returns aSubTempwhen called asSubTemp.of(1), while the hard-coded name always returns the base type.
Common mistakes
- Assuming class declarations hoist like function declarations. They sit in the TDZ until their line runs.
- Treating
_privateas a boundary. It is a comment with syntax; only#is enforced. - Passing a method that reads a private field as a bare callback, which throws a TypeError once
thisis lost. - Expecting instances to see statics.
instance.CONSTANTisundefined. - Adding a getter with no setter and being surprised when an assignment throws, because class bodies are strict.
- Using a class field arrow for every method out of habit, which puts one function object per method on every instance.
- Trusting
instanceoffor identity whenObject.create(Cls.prototype)passes it and a brand check does not.
Takeaways
typeof MyClassis"function": a class is a constructor plus a prototype, with guardrails.- Class declarations are hoisted but held in the TDZ, and class bodies are always strict.
- Methods land on the prototype and are non-enumerable; fields land on the instance and are enumerable.
- Statics live on the class, are inherited by subclasses, and are invisible to instances.
#privatenames are enforced by the parser, not by convention, and#x in objis the brand check.Symbol.hasInstancecan redefineinstanceof, so prefer a brand check when identity actually matters.