JavaScript Classes
JavaScript classes are syntactic sugar over its prototype model — but the syntax is the kind of sugar that completely changes how you write code. Classes give you constructors, methods, static members, getters/setters, and (since 2022) real private fields. This chapter covers all of it, plus when you shouldn’t reach for a class at all.
Defining a Class
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
greet() {
return `Hi, I'm ${this.name}`;
}
}
const u = new User("Ada", "ada@example.com");
console.log(u.greet()); // Output: Hi, I'm Ada
console.log(u instanceof User); // Output: true
Three things going on:
constructorruns onnew— sets up instance state viathis- Plain methods (no
function, no commas between them) live onUser.prototype newallocates the object and wires up the prototype chain
Class Expressions
Classes can be expressions, just like functions:
const User = class {
constructor(name) { this.name = name; }
};
// Named class expression — name is only visible inside
const Logger = class L {
log(msg) { console.log(`[${L.name}]`, msg); }
};
Useful for factories or one-off helpers.
Class Fields (Public)
You can declare fields outside the constructor — they initialise on each instance:
class Counter {
count = 0; // public field
increment() { this.count++; }
}
const c = new Counter();
c.increment();
console.log(c.count); // Output: 1
Field initialisers can reference this and run after the implicit super() call.
Private Fields — #name
True private state, enforced by the language (not a convention):
class BankAccount {
#balance = 0; // private
deposit(amount) {
if (amount <= 0) throw new Error("must be positive");
this.#balance += amount;
}
get balance() { return this.#balance; }
}
const acct = new BankAccount();
acct.deposit(100);
console.log(acct.balance); // Output: 100
console.log(acct.#balance); // ❌ SyntaxError — outside class
#name is not the same as _name. It’s lexically scoped to the class body and cannot be read or written from outside, not even with bracket notation.
| Convention | Enforced? | Visible outside? |
|---|---|---|
obj._name |
No (just a naming hint) | Yes |
obj.#name |
Yes (syntax error otherwise) | No |
Getters and Setters
Computed properties that look like fields:
class Temperature {
#celsius;
constructor(c) { this.#celsius = c; }
get celsius() { return this.#celsius; }
set celsius(v) { if (v < -273.15) throw new RangeError("absolute zero"); this.#celsius = v; }
get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
set fahrenheit(f){ this.celsius = (f - 32) * 5 / 9; }
}
const t = new Temperature(25);
console.log(t.fahrenheit); // Output: 77
t.fahrenheit = 100;
console.log(t.celsius); // Output: 37.777...
Use getters/setters to:
- Validate on assignment
- Expose computed values (no parentheses at call site)
- Wrap legacy attribute names without breaking callers
Static Members
Belong to the class itself, not instances:
class MathUtil {
static PI = 3.14159;
static square(n) { return n * n; }
static #counter = 0; // static private
static next() { return ++MathUtil.#counter; }
}
console.log(MathUtil.PI); // Output: 3.14159
console.log(MathUtil.square(5)); // Output: 25
console.log(MathUtil.next()); // Output: 1
Common static patterns:
- Constants:
class Color { static RED = "#f00"; } - Factory methods:
Date.now(),URL.canParse(s) - Helpers:
Array.isArray()
Static Initialisation Blocks (ES2022)
For more complex one-time setup:
class Config {
static defaults;
static {
Config.defaults = loadFromDisk();
console.log("Config initialised");
}
}
Runs once, when the class is evaluated.
Methods vs Arrow Function Fields
class Button {
label = "Click";
handleClick() { // method — `this` depends on caller
console.log(this?.label);
}
handleClickArrow = () => { // arrow field — `this` always bound
console.log(this.label);
};
}
const b = new Button();
const m1 = b.handleClick;
const m2 = b.handleClickArrow;
m1(); // Output: undefined (this lost)
m2(); // Output: Click (this captured)
Trade-off:
| Method (on prototype) | Arrow field | |
|---|---|---|
| Where stored | Once on Class.prototype |
Once per instance |
this when detached |
Lost | Preserved |
| Memory | Cheaper | More per instance |
Inheritance / super |
Works | No super |
Tip: Use methods by default. Reach for arrow-field methods only when you’ll regularly pass them as event handlers or callbacks where binding matters.
How Classes Map to Prototypes
class is sugar. Behind the scenes:
class User { function User(name) { this.name = name; }
constructor(name) { User.prototype.greet = function() {
this.name = name; return `Hi ${this.name}`;
} };
greet() { return … }
}
u.greet() works because Object.getPrototypeOf(u) === User.prototype. Knowing this helps when you read older code or debug instanceof.
instanceof and Duck Typing
const u = new User("Ada");
u instanceof User; // true
u instanceof Object; // true (everything is)
typeof u; // "object"
// Realm caveat: instanceof can lie across iframes / vm contexts.
// For cross-realm tests prefer:
Array.isArray(arr);
A Realistic Example — A Stopwatch
class Stopwatch {
#startedAt = null;
#elapsed = 0;
start() {
if (this.#startedAt) return; // already running
this.#startedAt = performance.now();
}
stop() {
if (!this.#startedAt) return;
this.#elapsed += performance.now() - this.#startedAt;
this.#startedAt = null;
}
reset() {
this.#startedAt = null;
this.#elapsed = 0;
}
get isRunning() { return this.#startedAt !== null; }
get ms() {
const live = this.#startedAt ? performance.now() - this.#startedAt : 0;
return this.#elapsed + live;
}
toString() { return `${(this.ms / 1000).toFixed(2)}s`; }
}
const sw = new Stopwatch();
sw.start();
// … do work …
sw.stop();
console.log(`Took ${sw}`); // Output: Took 1.23s
Encapsulation (private state), computed properties (getters), and clear lifecycle methods — exactly what classes are for.
When Not to Use a Class
- Plain data? Use an object literal
{ name, age } - One-off function? Use a function, not a class with a single method
- Need composition over inheritance? Plain functions + closures are usually clearer
- Want immutability? Pure functions over object methods
Don’t reach for class just because other languages would.
Common Mistakes
// ❌ Forgetting `new`
const u = User("Ada"); // TypeError — must be called with new
// ❌ Method declared with comma
class A {
one() {}, two() {} // SyntaxError
}
// ❌ Mistaking _underscore for private
class A { _secret = 1; }
new A()._secret; // 1 — still public
// ❌ Calling `this` in static method
class A { static x = 1; static f() { return this.x; } }
A.f(); // 1 — works, but `this` is the class
// ❌ Mutating shared array on prototype
class A { tags = []; }
class B { tags = []; } // ✅ each instance gets its own
Best Practices Checklist
- ✅ Use
classwhen you have state + behaviour that travels together - ✅ Use
#namefor true private fields — not_name - ✅ Use getters/setters for validation and computed properties
- ✅ Use
staticfor constants and pure helpers belonging to the type - ✅ Use methods by default; arrow-field methods only when binding matters
- ✅ Always call with
new - ❌ Don’t reach for classes for plain data (use objects) or pure logic (use functions)
- ❌ Don’t store mutable defaults (
[],{}) on the prototype
What’s Next?
A single class is rarely the end of the story. Next we extend classes with inheritance: extends, super, method overriding, and when composition is a better choice.