ES6 classes provide a clean, readable syntax for the same prototype-based inheritance you learned in the previous lesson. Classes are now the standard way to write object-oriented JavaScript โ you will see them in React, Vue, Angular, and virtually every modern codebase. In this lesson you will master the full class syntax: constructors, instance methods, static methods, getters and setters, private fields, and inheritance with extends and super.
Class Anatomy
| Feature | Syntax | Purpose |
|---|---|---|
| Constructor | constructor(params) { this.x = x; } |
Runs once on new โ initialise instance |
| Instance method | methodName() { } |
Available on every instance via prototype |
| Static method | static methodName() { } |
Called on the class โ not instances |
| Getter | get prop() { return ...; } |
Access like a property โ computes on read |
| Setter | set prop(val) { this._p = val; } |
Validate or transform on assignment |
| Private field | #fieldName |
Truly private โ enforced by JS engine |
| Static field | static count = 0 |
Shared value across all instances |
| Inherit | class Child extends Parent { } |
Child inherits all Parent methods |
| super constructor | super(args) |
Call parent constructor โ required before this |
| super method | super.method() |
Call parent’s version of overridden method |
Static vs Instance
| Feature | Instance | Static |
|---|---|---|
| Access via | instance.method() |
ClassName.method() |
this inside |
The instance | The class itself |
| Best for | Behaviour needing instance data | Factories, utilities, class-level constants |
| Example | user.getFullName() |
User.fromJSON(data) |
Private Fields vs Convention
| Feature | Private Field #name |
Convention _name |
|---|---|---|
| Truly private? | Yes โ TypeError if accessed outside | No โ just a naming hint to other developers |
| Accessible by subclasses? | No โ private to defining class only | Yes โ no enforcement |
| Prefer in new code? | Yes | Only for legacy compatibility |
let and const. Class bodies always run in strict mode automatically, so accidental globals and duplicate parameters become errors.#fieldName) for genuinely internal state. They are enforced by the JavaScript engine โ accessing them outside the class throws a SyntaxError at parse time, not just a runtime surprise. This makes your class’s public API contract explicit and protects internal state from accidental modification.super() before accessing this. If you try to use this before super(), you get a ReferenceError: “Must call super constructor in derived class before accessing ‘this’.” The parent must initialise the instance first.Basic Example
// โโ Base class with private fields and static members โโโโโโโโโโโโโโโโโโโโโ
class BankAccount {
#balance;
#transactions;
static #totalAccounts = 0;
static interestRate = 0.02;
constructor(owner, initialBalance = 0) {
if (initialBalance < 0) throw new RangeError('Balance cannot be negative');
this.owner = owner;
this.#balance = initialBalance;
this.#transactions = [];
this.id = ++BankAccount.#totalAccounts;
}
get balance() { return this.#balance; }
get transactionCount() { return this.#transactions.length; }
deposit(amount) {
if (amount <= 0) throw new RangeError('Deposit must be positive');
this.#balance += amount;
this.#transactions.push({ type: 'deposit', amount, balance: this.#balance });
return this;
}
withdraw(amount) {
if (amount <= 0) throw new RangeError('Withdrawal must be positive');
if (amount > this.#balance) throw new Error('Insufficient funds');
this.#balance -= amount;
this.#transactions.push({ type: 'withdraw', amount, balance: this.#balance });
return this;
}
getStatement() {
return this.#transactions
.map(t => `${t.type.padEnd(8)} $${t.amount.toFixed(2).padStart(8)} | Bal: $${t.balance.toFixed(2)}`)
.join('
');
}
toString() { return `Account #${this.id} (${this.owner}): $${this.#balance.toFixed(2)}`; }
static fromJSON({ owner, balance }) { return new BankAccount(owner, balance); }
static getTotalAccounts() { return BankAccount.#totalAccounts; }
}
// โโ Subclass โ extends with super โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class SavingsAccount extends BankAccount {
#limit;
constructor(owner, balance, withdrawalLimit = 500) {
super(owner, balance); // must be first โ sets up inherited props
this.#limit = withdrawalLimit;
}
// Override parent method
withdraw(amount) {
if (amount > this.#limit) {
throw new Error(`Savings limit is $${this.#limit}`);
}
return super.withdraw(amount); // delegate to parent
}
applyInterest() {
const interest = this.balance * BankAccount.interestRate;
this.deposit(interest);
return this;
}
}
// Usage
const checking = new BankAccount('Alice', 1000);
checking.deposit(500).withdraw(200);
console.log(checking.toString()); // Account #1 (Alice): $1300.00
console.log(checking.getStatement());
const savings = new SavingsAccount('Bob', 5000, 300);
savings.deposit(1000).applyInterest();
console.log(savings.toString()); // Account #2 (Bob): $6120.00
try {
savings.withdraw(400); // over limit
} catch (err) {
console.error(err.message); // 'Savings limit is $300'
}
console.log(BankAccount.getTotalAccounts()); // 2
console.log(savings instanceof BankAccount); // true
How It Works
Step 1 โ Private Fields Are Hard Private
#balance cannot be read or written from outside the class โ not even from subclasses. Accessing account.#balance externally is a SyntaxError at parse time. This is language-level enforcement, not a convention.
Step 2 โ Getters Present Computed Properties
get balance() makes account.balance look like a plain property but actually invokes a method. The caller sees no difference between a getter and a plain property. Without a setter, the property is effectively read-only.
Step 3 โ super() Must Come First
SavingsAccount‘s constructor calls super(owner, balance) to run BankAccount‘s constructor, which sets up this.owner, #balance, #transactions, and this.id. The parent initialises the instance’s shape before the subclass adds its own properties.
Step 4 โ super.method() Delegates Upward
SavingsAccount.withdraw adds a limit check, then calls super.withdraw(amount) to let BankAccount handle the actual balance update and transaction recording. This is the Template Method pattern โ the subclass adds behaviour and delegates the rest.
Step 5 โ Static Methods Are Class-Level Utilities
BankAccount.fromJSON(data) is a static factory โ an alternative constructor for different input formats. this inside a static method refers to the class itself, not an instance. Static methods are ideal for parsing, validation, constants, and alternative construction patterns.
Real-World Example: State Machine
// state-machine.js
class StateMachine {
#state;
#transitions;
#listeners = new Map();
constructor(initialState, transitions) {
this.#state = initialState;
this.#transitions = transitions;
}
get state() { return this.#state; }
can(event) { return !!this.#transitions[this.#state]?.[event]; }
transition(event, data = {}) {
const next = this.#transitions[this.#state]?.[event];
if (!next) throw new Error(`Invalid: ${this.#state} + ${event}`);
const prev = this.#state;
this.#state = next;
this.#fire('transition', { from: prev, to: next, event, data });
return this;
}
on(name, fn) {
(this.#listeners.get(name) ?? this.#listeners.set(name, new Set()).get(name)).add(fn);
return () => this.#listeners.get(name)?.delete(fn);
}
#fire(name, payload) {
this.#listeners.get(name)?.forEach(fn => fn(payload));
}
static create(initial, transitions) { return new StateMachine(initial, transitions); }
}
const order = StateMachine.create('pending', {
pending: { confirm: 'confirmed', cancel: 'cancelled' },
confirmed: { ship: 'shipped', cancel: 'cancelled' },
shipped: { deliver: 'delivered' },
delivered: {}, cancelled: {},
});
order.on('transition', ({ from, to }) => console.log(` ${from} -> ${to}`));
order.transition('confirm').transition('ship').transition('deliver');
console.log(order.state); // 'delivered'
console.log(order.can('cancel')); // false
Common Mistakes
Mistake 1 โ Accessing this before super()
โ Wrong:
class Dog extends Animal {
constructor(name) {
this.breed = 'Lab'; // ReferenceError โ super() not called yet!
super(name);
}
}
โ Correct โ super first:
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
}
Mistake 2 โ Arrow method prevents super() calls in subclasses
โ Wrong โ arrow class fields cannot be overridden via super:
class Animal { speak = () => `${this.name} speaks`; }
class Dog extends Animal {
speak = () => `Woof! ${super.speak()}`; // TypeError โ arrow has no [[HomeObject]]
}
โ Correct โ use regular method syntax for overrideable behaviour:
class Animal { speak() { return `${this.name} speaks`; } }
class Dog extends Animal { speak() { return `Woof! ${super.speak()}`; } }
Mistake 3 โ Forgetting class bodies are strict mode
โ Note โ no action needed, just be aware:
class MyClass {
method() {
// 'use strict' is automatic here
// undeclared variables throw ReferenceError
// duplicate params are a SyntaxError
// this inside a plain function call is undefined, not window
}
}
Quick Reference
| Feature | Syntax |
|---|---|
| Class | class Name { constructor() {} method() {} } |
| Private field | #field โ declare at top of class body |
| Static method / field | static create() {} / static count = 0 |
| Getter / Setter | get x() {} / set x(v) {} |
| Inherit | class Child extends Parent {} |
| Call parent constructor | super(args) โ must be first in subclass |
| Call parent method | super.methodName() |
| Type check | obj instanceof ClassName |