Classes

โ–ถ Try It Yourself

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
Note: Classes are NOT hoisted like function declarations. You cannot use a class before the line it is defined on โ€” it is in the TDZ just like let and const. Class bodies always run in strict mode automatically, so accidental globals and duplicate parameters become errors.
Tip: Use private fields (#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.
Warning: In a subclass constructor, you must call 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
    }
}

▶ Try It Yourself

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

🧠 Test Yourself

In a subclass constructor, when must super() be called?





โ–ถ Try It Yourself