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' true

The 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.

BehaviourConstructor functionClass
Called without newruns, corrupting thisthrows a TypeError
Hoistingdeclaration is hoisted and callablehoisted but in the TDZ, so not usable early
Body modeinherits the surrounding modealways strict
Methods enumerableyes if assigned with =no, so for...in and spread skip them
Private stateclosures or a naming conventionreal #private members, enforced by the engine
Inheritancemanual 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
MemberLives onIn Object.keys?Notes
method() {}the prototypenoshared by every instance, non-enumerable
field = valueeach instanceyesinitialised in order, before the constructor body runs
static m() {}the class itselfnonot visible on instances
static f = vthe class itselfnoevaluated once, when the class is defined
static { ... }runs once at definitionn/afor multi-step setup and private access
get x() {} / set x(v) {}the prototypenoa property backed by functions
#x / #m()each instance, keyed privatelynonot 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);        // 3

Initialisation 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); } // TypeError

The 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...in skip 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 instanceof on the right-hand side

Classes did not add a new object model. They added guardrails to the one we had.

The honest summary

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.keys nor JSON.stringify sees 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 the undefined you would get from var.
Which statement about #private members 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.stringify and proxy traps cannot reach them. The restriction is enforced at parse time, which is why #x in obj exists as the sanctioned way to test for one.
Why is static of(x) { return new this(x); } usually better than return new Temp(x);?
this in a static method is the class it was called on, so a subclass gets an instance of itself — Static methods are inherited, and inside one this is whichever class received the call. new this(x) therefore returns a SubTemp when called as SubTemp.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 _private as 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 this is lost.
  • Expecting instances to see statics. instance.CONSTANT is undefined.
  • 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 instanceof for identity when Object.create(Cls.prototype) passes it and a brand check does not.

Takeaways

  • typeof MyClass is "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.
  • #private names are enforced by the parser, not by convention, and #x in obj is the brand check.
  • Symbol.hasInstance can redefine instanceof, so prefer a brand check when identity actually matters.