JavaScript Classes | ES6 Class Syntax Explained

Key takeaways

This guide explains what ES6 class syntax actually compiles down to (the prototype chain, not a new object model), then works through getters/setters, static members and static blocks, inheritance with extends/super, ES2022 private fields versus the older WeakMap/closure privacy convention, and the classic this-binding pitfall when passing class methods as callbacks.

ES6 (ES2015) gave JavaScript a class keyword, and it is easy to read that as “JavaScript finally got classes like Java or C++.” It didn’t. Underneath the keyword, JavaScript is still the same prototype-based language it always was — class is a more convenient, more restrictive syntax for building exactly the same function + .prototype structures developers were already writing by hand. Understanding that equivalence is not a trivia fact; it explains why this behaves the way it does when you extract a method, why private fields (#field) are enforced differently from anything JavaScript had before, why a subclass constructor is forbidden from touching this before calling super(), and why class declarations are not hoisted the way function declarations are. This article works through class syntax feature by feature, and at each stop explains what it desugars to, why the feature exists, when to reach for it, and where developers most commonly get bitten.

Introduction

What is a class?

A class is a template for creating objects — a single place that defines the shape (fields) and behavior (methods) that every instance created from it will share. Before ES6, that template had to be built manually out of a constructor function plus a shared .prototype object.

Before classes (ES5):

// Constructor function
function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.greet = function() {
    console.log(`Hello, I'm ${this.name}.`);
};
const person = new Person("Alice", 25);
person.greet();  // Hello, I'm Alice.

With classes (ES6+):

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    
    greet() {
        console.log(`Hello, I'm ${this.name}.`);
    }
}
const person = new Person("Alice", 25);
person.greet();  // Hello, I'm Alice.

Both snippets above produce objects that behave identically from the outside — person.greet() prints the same line, person instanceof Person is true in both, and person.__proto__ points at the same kind of shared object in both. That is not a coincidence; it is the entire point of the next section.

Classes are syntactic sugar, not a new object model

Here is what the class Person { ... } declaration above actually does, roughly translated back into ES5 terms: it creates a function named Person (the thing typeof Person === "function" confirms), it attaches greet to Person.prototype as a non-enumerable method (so it won’t show up in a for...in loop or JSON.stringify, unlike a property assigned with Person.prototype.greet = function() {}), and it marks the function so that calling it without new throws instead of silently running with this bound to the global object or undefined. Every instance created with new Person(...) still gets its own [[Prototype]] internal slot pointing at Person.prototype, and method lookup still works by walking that chain — person.greet is not copied onto every instance; every instance shares the one greet function object that lives on the prototype.

That shared-prototype structure is what makes property/method lookup work, and it’s worth seeing as a diagram rather than just prose, because the same lookup mechanism explains several “why does this work” questions later in this article (method overriding, super, instanceof):

flowchart BT
    inst["dog instance<br/>(own property: name)"] -->|"[[Prototype]]"| dogProto["Dog.prototype<br/>speak(), fetch()"]
    dogProto -->|"[[Prototype]]"| animalProto["Animal.prototype<br/>speak(), move()"]
    animalProto -->|"[[Prototype]]"| objProto["Object.prototype<br/>toString(), hasOwnProperty()"]
    objProto -->|"[[Prototype]]"| nullNode["null (chain ends)"]

When you call dog.speak(), the engine does not “invoke a class method” in some special sense — it looks for an own property named speak directly on the dog instance (there isn’t one), then walks up to Dog.prototype (found — that’s the overridden version), and stops there. If Dog.prototype didn’t define speak, the search would continue to Animal.prototype, and only fail if it reached null with no match. This is exactly the same algorithm the ES5 function + .prototype version uses; class just gives you a cleaner syntax for wiring the chain together, plus a handful of safety rails (the new-only restriction, the temporal dead zone for the class binding itself, and non-enumerable methods) that the manual version didn’t have for free.

Three concrete differences from the old pattern are worth calling out because they change real behavior, not just style:

  • No hoisting the way functions get hoisted. A function declaration is fully hoisted, so calling it above its definition works. A class declaration is hoisted only as a binding, not initialized — referencing it before the class line throws a ReferenceError from the temporal dead zone, the same rule let/const follow.
  • Strict mode is always on inside a class body, even in a file that hasn’t opted into "use strict" anywhere else — so silent mistakes like assigning to an undeclared variable inside a method throw instead of creating an accidental global.
  • Methods defined in a class body are non-enumerable. for (const key in dog) will list name and breed (instance fields set in the constructor) but not speak or fetch, whereas Person.prototype.greet = function() {} in the ES5 version is enumerable unless you manually call Object.defineProperty with enumerable: false.

Class basics

Defining a class

class Rectangle {
    // Constructor: runs when an instance is created
    constructor(width, height) {
        this.width = width;
        this.height = height;
    }
    
    // Methods
    getArea() {
        return this.width * this.height;
    }
    
    getPerimeter() {
        return 2 * (this.width + this.height);
    }
    
    // Calling other methods
    describe() {
        return `Area: ${this.getArea()}, Perimeter: ${this.getPerimeter()}`;
    }
}
// Create an instance
const rect = new Rectangle(10, 5);
console.log(rect.getArea());       // 50
console.log(rect.getPerimeter());  // 30
console.log(rect.describe());      // Area: 50, Perimeter: 30

constructor is the single method the engine calls automatically when new Rectangle(10, 5) runs — it exists to set up instance-specific state (here, width and height, which differ per rectangle), while getArea, getPerimeter, and describe live once on Rectangle.prototype and are shared by every instance, because their logic doesn’t vary per instance the way the data does. describe() calling this.getArea() internally is ordinary method dispatch through this — nothing about it is class-specific, but it’s a useful pattern to notice: methods on the same class can and should call each other through this rather than duplicating logic.

Class expressions

Classes can also be values, not just declarations — useful when a class needs to be created conditionally, returned from a factory function, or kept anonymous because nothing outside a small scope needs to name it.

// Anonymous class expression
const Person = class {
    constructor(name) {
        this.name = name;
    }
    
    greet() {
        console.log(`Hello, ${this.name}!`);
    }
};
const person = new Person("Alice");
person.greet();

A class expression can optionally carry its own internal name, which is visible inside the class body (useful for recursive references or clearer stack traces) but not in the enclosing scope:

// Named class expression — PersonClass is only visible inside the class body
const Greeter = class PersonClass {
    constructor(name) {
        this.name = name;
    }
    
    describe() {
        // PersonClass would be usable here for self-reference; Greeter is the outer binding
        return `${this.name} (${PersonClass.name})`;
    }
};
const greeter = new Greeter("Alice");
console.log(greeter.describe());  // Alice (PersonClass)

Note that the two snippets above intentionally use different binding names (Person and Greeter) — reusing const Person for both would be a SyntaxError (you cannot redeclare a const in the same scope), which is a common copy-paste mistake when experimenting with class expressions in a single script file or REPL session.


Getters and setters

Defining getters/setters

class Circle {
    constructor(radius) {
        this._radius = radius;  // “private by convention” (_prefix)
    }
    
    // Getter: read like a property
    get radius() {
        return this._radius;
    }
    
    // Setter: assign like a property
    set radius(value) {
        if (value < 0) {
            throw new Error("Radius must be positive");
        }
        this._radius = value;
    }
    
    // Computed properties
    get area() {
        return Math.PI * this._radius ** 2;
    }
    
    get diameter() {
        return this._radius * 2;
    }
    
    set diameter(value) {
        this._radius = value / 2;
    }
}
// Usage
const circle = new Circle(5);
console.log(circle.radius);  // 5 (getter)
console.log(circle.area);    // 78.53981633974483
circle.radius = 10;  // setter
console.log(circle.area);  // 314.1592653589793
circle.diameter = 20;  // diameter setter
console.log(circle.radius);  // 10
// circle.radius = -5;  // Error: Radius must be positive

get and set let a method masquerade as a plain property: circle.radius looks like reading a field, but it actually runs a function, and circle.radius = 10 looks like assignment but runs the set radius(value) function with value = 10. This buys you validation (the negative-radius guard above) and derived values (area, diameter) without changing the call site — code that reads circle.area doesn’t need to know or care that it’s actually invoking Math.PI * this._radius ** 2 on every access.

The _radius underscore prefix is a naming convention only — it signals “please treat this as internal” to other developers, but circle._radius is a completely ordinary, publicly readable and writable property; nothing in the language enforces the underscore. That’s the gap ES2022 private fields (#radius, covered later in this article) were introduced to close. Two pitfalls are worth watching for with getters/setters specifically: first, defining a get radius() and a plain instance property assignment this.radius = radius in the constructor throws, because you can’t have both an accessor and a data property with the same key — that’s why the constructor above assigns to this._radius, not this.radius. Second, like regular class methods, getters and setters live on the prototype and run their function body on every access, so a getter that does expensive work (a database call, a large computation) will silently re-run that work every single time it’s read — cache the result yourself if that becomes a cost.


Static methods, properties, and static blocks

The static keyword

class MathUtils {
    // Static property
    static PI = 3.14159;
    
    // Static method: callable without an instance
    static add(a, b) {
        return a + b;
    }
    
    static max(...numbers) {
        return Math.max(...numbers);
    }
    
    // Factory-style helper
    static createCircle(radius) {
        return new Circle(radius);
    }
}
// Call static members on the class
console.log(MathUtils.add(10, 20));  // 30
console.log(MathUtils.max(1, 5, 3));  // 5
console.log(MathUtils.PI);  // 3.14159
// Not available on instances
// const util = new MathUtils();
// util.add(1, 2);  // TypeError

static members attach directly to the class function object (MathUtils), not to MathUtils.prototype, which is why instances never see them — new MathUtils().add is undefined, not a bound version of add. This is the right home for logic that operates on the class or independent of any particular instance’s state: pure helper functions (add, max), shared constants (PI), and factory functions that construct and return instances (createCircle) all fit that description, because none of them need this to refer to a specific Rectangle or Circle.

Static initialization blocks (ES2022)

Static properties like PI = 3.14159 above cover simple cases, but sometimes a class needs to run multi-step setup logic once, at class-definition time, that a single expression can’t express cleanly — populating a lookup table, reading configuration, or validating some invariant across several static fields. A static block runs exactly once, when the class itself is defined, and has access to private static fields that code outside the class cannot touch:

class ColorPalette {
    static #hexByName = new Map();

    // Static block: runs once, when the class is defined — not per instance
    static {
        const entries = [["red", "#ff0000"], ["green", "#00ff00"], ["blue", "#0000ff"]];
        for (const [name, hex] of entries) {
            ColorPalette.#hexByName.set(name, hex);
        }
        console.log(`ColorPalette initialized with ${ColorPalette.#hexByName.size} colors`);
    }

    static hexOf(name) {
        return ColorPalette.#hexByName.get(name) ?? null;
    }
}
console.log(ColorPalette.hexOf("green"));  // #00ff00

This runs once — the log line fires the moment the module loads ColorPalette, not once per new ColorPalette() (there’s no instance here at all; every member in this example is static). Reach for a static block when initialization needs more than one expression, needs a loop or a try/catch, or needs to write to a private static field that must stay inaccessible from outside the class — a plain static #hexByName = new Map([...]) field initializer can’t easily express the loop above, and exposing a mutable public static Map instead would let any caller silently corrupt the shared table.

Practical example: factory pattern

class User {
    constructor(name, email, role) {
        this.name = name;
        this.email = email;
        this.role = role;
    }
    
    // Static factories
    static createAdmin(name, email) {
        return new User(name, email, "admin");
    }
    
    static createGuest(name) {
        return new User(name, `${name}@guest.com`, "guest");
    }
    
    hasPermission(permission) {
        const permissions = {
            admin: ["read", "write", "delete"],
            user: ["read", "write"],
            guest: ["read"]
        };
        return permissions[this.role].includes(permission);
    }
}
// Usage
const admin = User.createAdmin("Admin", "[email protected]");
const guest = User.createGuest("Guest");
console.log(admin.hasPermission("delete"));  // true
console.log(guest.hasPermission("write"));   // false

Note the guest: ["read"] line above — it’s written as a quoted string inside an array. A single missing pair of quotes there (guest: [read]) would try to evaluate read as an identifier rather than treat it as the string "read", and JavaScript would throw a ReferenceError: read is not defined the moment hasPermission ran for a guest, since nothing named read exists in scope. It’s a small typo that only surfaces at runtime, on a code path that might not be exercised until a guest user actually calls hasPermission — exactly the kind of bug that’s easy to miss in review and worth double-checking whenever a permissions or config object is built from bare-looking identifiers.

Static factory methods like createAdmin/createGuest exist to give callers descriptive, self-documenting ways to construct an object, instead of forcing every call site to remember new User(name, email, "admin") with "admin" as a magic string. They’re also the natural place to put construction logic that varies (computing a default guest email here) without cluttering the constructor itself with branches.


Inheritance

extends

// Base class
class Animal {
    constructor(name) {
        this.name = name;
    }
    
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
    
    move() {
        console.log(`${this.name} moves.`);
    }
}
// Derived classes
class Dog extends Animal {
    constructor(name, breed) {
        super(name);  // Call parent constructor (required!)
        this.breed = breed;
    }
    
    // Override
    speak() {
        console.log(`${this.name}: Woof!`);
    }
    
    fetch() {
        console.log(`${this.name} fetches the ball.`);
    }
}
class Cat extends Animal {
    speak() {
        console.log(`${this.name}: Meow~`);
    }
}
// Usage
const dog = new Dog("Rex", "Jindo");
dog.speak();  // Rex: Woof!
dog.move();   // Rex moves. (inherited)
dog.fetch();  // Rex fetches the ball.
const cat = new Cat("Luna");
cat.speak();  // Luna: Meow~
// instanceof
console.log(dog instanceof Dog);     // true
console.log(dog instanceof Animal);  // true
console.log(dog instanceof Cat);     // false

extends is what wires Dog.prototype’s own [[Prototype]] link to Animal.prototype — exactly the chain diagrammed earlier in this article. That’s why dog.move() works even though Dog never defines move: the lookup fails on dog itself, fails on Dog.prototype too, and succeeds on Animal.prototype. It’s also why overriding works the way it does — Dog.prototype.speak is found before the search ever reaches Animal.prototype.speak, so the more specific version wins without needing any special “override” keyword. Cat doesn’t define a constructor at all here, which is legal: a derived class with no explicit constructor gets an implicit one that just forwards all arguments to super(...args), so new Cat("Luna") still correctly sets this.name.

instanceof walks the same prototype chain to answer its question — dog instanceof Animal is true because Animal.prototype appears somewhere in dog’s chain, and dog instanceof Cat is false because Cat.prototype never does, even though Dog and Cat are siblings under the same base class.

The super keyword

class Employee {
    constructor(name, salary) {
        this.name = name;
        this.salary = salary;
    }
    
    getInfo() {
        return `${this.name} - $${this.salary.toLocaleString()}`;
    }
    
    work() {
        return `${this.name} is working.`;
    }
}
class Manager extends Employee {
    constructor(name, salary, teamSize) {
        super(name, salary);
        this.teamSize = teamSize;
    }
    
    // Extend parent behavior
    getInfo() {
        const baseInfo = super.getInfo();
        return `${baseInfo} (team size: ${this.teamSize})`;
    }
    
    manageTeam() {
        return `${this.name} manages a team of ${this.teamSize}.`;
    }
}
const manager = new Manager("Sarah", 5000000, 5);
console.log(manager.getInfo());    // Sarah - $5,000,000 (team size: 5)
console.log(manager.work());       // Sarah is working. (inherited)
console.log(manager.manageTeam()); // Sarah manages a team of 5.

super has two distinct jobs depending on where it appears. Inside a derived constructor, super(name, salary) calls Employee’s constructor with this already pointing at the object being built — and it is mandatory before a derived constructor can read or write this at all, which is why this.teamSize = teamSize only appears after the super(...) line. Outside the constructor, super.getInfo() inside Manager.prototype.getInfo means “call the version of getInfo one step up the prototype chain, but keep running with the current this” — that’s how Manager.getInfo can build on Employee.getInfo’s output (baseInfo) instead of reimplementing the salary formatting from scratch. This pattern — override a method, then call the parent’s version via super and extend its result — is the normal way to add behavior in a subclass without duplicating the base logic.


Private fields (ES2022+)

# prefix

class BankAccount {
    // Private fields
    #balance;
    
    constructor(owner, balance) {
        this.owner = owner;
        this.#balance = balance;
    }
    
    // Public methods
    deposit(amount) {
        if (amount > 0) {
            this.#balance += amount;
            return true;
        }
        return false;
    }
    
    withdraw(amount) {
        if (amount > 0 && amount <= this.#balance) {
            this.#balance -= amount;
            return true;
        }
        return false;
    }
    
    getBalance() {
        return this.#balance;
    }
    
    // Private method
    #log(message) {
        console.log(`[${this.owner}] ${message}`);
    }
}
const account = new BankAccount("Alice", 10000);
console.log(account.owner);  // Alice
// console.log(account.#balance);  // SyntaxError: Private field
account.deposit(5000);
console.log(account.getBalance());  // 15000

This is the feature that actually delivers what the _radius underscore convention from Section 2 only implied. Before private fields shipped (ES2022), JavaScript had no language-level privacy at all — every convention developers used was a workaround, and each had a real cost:

  • Underscore prefix (this._balance): pure convention, zero enforcement — any caller can still read or write account._balance directly, and nothing stops them.
  • Closures over constructor-local variables: genuinely private (the variable really is inaccessible from outside), but every method that needs access must be created inside the constructor as a closure, which means a new function object per instance instead of one shared method on the prototype — costly if you create many instances.
  • WeakMap keyed by instance: also genuinely private and lets methods live on the prototype, but it’s verbose (const balances = new WeakMap(); ... balances.set(this, balance)) and easy to get subtly wrong (forgetting to clean up, or exposing the WeakMap itself from the wrong scope).

#balance is different from all three: it is a distinct kind of class member, enforced by the JavaScript engine itself, not by convention. account.#balance outside the class body isn’t just “hard to reach” — it’s a SyntaxError, because #balance isn’t even valid syntax unless it’s written lexically inside the BankAccount class body. This is sometimes called “hard privacy” or a brand check: the engine can verify at the bytecode level whether a given object actually has a particular private field, which is also why #log can safely be a genuinely private method (not just an underscore-prefixed public one) — there is no way to invoke account.#log(...) from outside BankAccount, ever, regardless of what tricks the caller tries (Object.keys, Reflect.ownKeys, and JSON.stringify all skip private fields entirely). Use #field by default for any state whose invariants the class needs to guarantee (like #balance never going negative through anything other than the checked withdraw method); reach for the underscore convention only in code that must still support environments without ES2022 private-field support, which is now rare.


The this pitfall: extracting a method as a callback

This is the single most common bug developers hit when they start using classes for UI or event-driven code, and it exists precisely because of everything explained in the desugaring section above: a class method is just a function sitting on a shared prototype, and in JavaScript, this is determined by how a function is called, not by where or how it was defined.

class Button {
    constructor(label) {
        this.label = label;
        this.clickCount = 0;
    }

    // Ordinary prototype method
    handleClick() {
        this.clickCount++;
        console.log(`${this.label} clicked ${this.clickCount} times`);
    }
}

const button = new Button("Save");
button.handleClick();               // "Save clicked 1 times" — called AS a method, this = button

const detached = button.handleClick;
// detached();                      // TypeError: Cannot read properties of undefined (reading 'clickCount')

// setTimeout(button.handleClick, 1000);   // Same failure: only the function VALUE is passed,
                                            // the `button.` part is not carried along with it

button.handleClick() works because the call expression itself supplies this — “call the function found at button.handleClick, with this set to button.” The moment you write const detached = button.handleClick, you’ve copied out the function value only; nothing ties it back to button anymore. Calling detached() invokes it as a bare function call, and because class bodies are always strict mode, this inside is undefined rather than silently falling back to the global object — hence the TypeError the instant the method tries to read this.clickCount. setTimeout(button.handleClick, 1000) fails for the identical reason: setTimeout just receives a function reference and calls it later with no particular this, exactly like the detached() case. The same failure shows up with addEventListener(el, "click", obj.method), array.map(obj.method), and any other API that takes “a function” and calls it on your behalf later.

flowchart TD
    A["button.handleClick()"] -->|"called AS a method"| B["this = button"]
    C["const fn = button.handleClick<br/>fn()"] -->|"detached, called as a plain function"| D["this = undefined (strict mode)"]
    E["setTimeout(button.handleClick, 1000)"] -->|"reference passed, called later by setTimeout"| D
    F["handleClick = () => {...}<br/>(arrow class field)"] -->|"this captured lexically, once, at construction"| G["this = button, always — regardless of call style"]

There are three standard fixes, and each has a real trade-off worth knowing rather than reaching for the same one everywhere:

class ButtonFixed {
    constructor(label) {
        this.label = label;
        this.clickCount = 0;
        // Fix 1: bind in the constructor — creates one bound function per instance,
        // but keeps handleClick as an ordinary shared prototype method too
        this.handleClick = this.handleClick.bind(this);
    }

    handleClick() {
        this.clickCount++;
        console.log(`${this.label} clicked ${this.clickCount} times`);
    }
}

class ButtonArrowField {
    label = "Save";
    clickCount = 0;

    // Fix 2: arrow function as a class field — this is captured lexically
    // at construction time and can never be rebound by how it's later called
    handleClick = () => {
        this.clickCount++;
        console.log(`${this.label} clicked ${this.clickCount} times`);
    };
}

// Fix 3: wrap at the call site instead of changing the class
// button.addEventListener("click", () => button.handleClick());

.bind(this) in the constructor and the arrow-function class field both solve the problem the same way — by permanently locking this to the instance, so it no longer matters how the resulting function is later called or passed around. They are not free, though: an ordinary method defined in the class body (handleClick() {...}) is created once, on the shared prototype, no matter how many instances exist. A .bind-in-constructor call or an arrow-function class field, by contrast, creates a brand-new function object per instance, because binding/capturing this isn’t something that can be shared across objects with different this values. For a handful of instances this difference is invisible; for thousands of instances (rows in a large list, particles in a simulation, nodes in a big tree) it means thousands of extra function allocations that a shared prototype method would have avoided — a real memory and GC-pressure cost, not just a style preference. Fix 3 (wrap the call at the call site with an arrow function, () => button.handleClick()) avoids that per-instance allocation cost entirely and is often the better choice when only one or two call sites actually need the binding, reserving the class-field approach for methods that genuinely need to be handed out as free-floating callbacks throughout the codebase.


Practical examples

Example 1: game characters

This example chains together most of the features covered above — inheritance, method overriding, and super calls into overridden methods — in a single working simulation, which is a good way to see how they compose in something closer to real code than an isolated snippet.

class Character {
    constructor(name, hp, attack) {
        this.name = name;
        this.hp = hp;
        this.maxHp = hp;
        this.attack = attack;
    }
    
    takeDamage(damage) {
        this.hp = Math.max(0, this.hp - damage);
        console.log(`${this.name} HP: ${this.hp}/${this.maxHp}`);
        return this.hp;
    }
    
    heal(amount) {
        this.hp = Math.min(this.maxHp, this.hp + amount);
        console.log(`${this.name} recovered! HP: ${this.hp}/${this.maxHp}`);
    }
    
    isAlive() {
        return this.hp > 0;
    }
    
    basicAttack(target) {
        console.log(`${this.name} attacks!`);
        return target.takeDamage(this.attack);
    }
}
class Warrior extends Character {
    constructor(name, hp, attack, defense) {
        super(name, hp, attack);
        this.defense = defense;
    }
    
    takeDamage(damage) {
        const reduced = Math.max(0, damage - this.defense);
        console.log(`${this.name} reduces the damage with ${this.defense} defense!`);
        return super.takeDamage(reduced);
    }
    
    shieldBash(target) {
        console.log(`${this.name}'s shield bash!`);
        return target.takeDamage(this.attack * 1.5);
    }
}
class Mage extends Character {
    constructor(name, hp, attack, mana) {
        super(name, hp, attack);
        this.mana = mana;
        this.maxMana = mana;
    }
    
    fireball(target) {
        if (this.mana < 20) {
            console.log("Not enough mana!");
            return 0;
        }
        
        this.mana -= 20;
        console.log(`${this.name}'s fireball! (mana: ${this.mana}/${this.maxMana})`);
        return target.takeDamage(this.attack * 2);
    }
}
// Simple battle
const warrior = new Warrior("Warrior", 150, 20, 5);
const mage = new Mage("Mage", 100, 30, 50);
console.log("=== Battle Start ===");
mage.basicAttack(warrior);
warrior.shieldBash(mage);
mage.fireball(warrior);
console.log("\n=== Battle Result ===");
console.log(`${warrior.name}: ${warrior.isAlive() ? "Alive" : "Defeated"}`);
console.log(`${mage.name}: ${mage.isAlive() ? "Alive" : "Defeated"}`);

Warrior.takeDamage is the interesting piece here: it doesn’t just override Character.takeDamage, it narrows the input and delegates — it computes a reduced damage value using this.defense, then hands that reduced number to super.takeDamage(reduced) so the actual HP bookkeeping and console logging stay defined in exactly one place (Character). This is the same “override, then call super, then extend” shape as the Manager.getInfo example earlier, applied to numeric transformation instead of string concatenation — a reusable pattern anywhere a subclass needs to modify an input or output around behavior it otherwise wants to inherit unchanged.

Example 2: shopping cart

This example is a good showcase for encapsulation through ordinary methods (rather than private fields, to keep it simple) — Cart never manipulates a product’s stock directly; it always calls product.decreaseStock(quantity), which in turn enforces the availability check through isAvailable. That indirection means the “can’t oversell stock” invariant is guaranteed in one place (Product) regardless of how many different call sites in Cart (or future code) end up adjusting a cart’s contents.

class Product {
    constructor(id, name, price, stock) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.stock = stock;
    }
    
    isAvailable(quantity = 1) {
        return this.stock >= quantity;
    }
    
    decreaseStock(quantity) {
        if (!this.isAvailable(quantity)) {
            throw new Error("Out of stock");
        }
        this.stock -= quantity;
    }
    
    toString() {
        return `${this.name} - $${this.price.toLocaleString()} (stock: ${this.stock})`;
    }
}
class Cart {
    constructor() {
        this.items = [];
    }
    
    addItem(product, quantity = 1) {
        if (!product.isAvailable(quantity)) {
            console.log(`${product.name} is out of stock`);
            return false;
        }
        
        const existing = this.items.find(item => item.product.id === product.id);
        
        if (existing) {
            existing.quantity += quantity;
        } else {
            this.items.push({ product, quantity });
        }
        
        console.log(`Added ${quantity} × ${product.name}`);
        return true;
    }
    
    removeItem(productId) {
        this.items = this.items.filter(item => item.product.id !== productId);
    }
    
    getTotal() {
        return this.items.reduce((total, item) => {
            return total + item.product.price * item.quantity;
        }, 0);
    }
    
    checkout() {
        this.items.forEach(item => {
            item.product.decreaseStock(item.quantity);
        });
        
        const total = this.getTotal();
        this.items = [];
        return total;
    }
    
    printCart() {
        console.log("=== Cart ===");
        this.items.forEach(item => {
            console.log(`${item.product.name} × ${item.quantity} = $${(item.product.price * item.quantity).toLocaleString()}`);
        });
        console.log(`Total: $${this.getTotal().toLocaleString()}`);
    }
}
// Usage
const laptop = new Product(1, "Laptop", 1200000, 5);
const mouse = new Product(2, "Mouse", 30000, 10);
const cart = new Cart();
cart.addItem(laptop, 1);
cart.addItem(mouse, 2);
cart.printCart();
const total = cart.checkout();
console.log(`Checkout complete: $${total.toLocaleString()}`);

Notice also that addItem uses this.items.find(item => item.product.id === product.id) — an arrow function passed to Array.prototype.find. This is a case where an arrow function is the correct and idiomatic choice inside a method, not just an alternative style: it needs to read this.items and be called back by find, but since it’s a throwaway callback used once inline (not a method extracted and handed off elsewhere, as in Section 6), there’s no meaningful memory cost to worry about, and using a function expression here would have required its own .bind(this) or an extra const self = this workaround to keep this pointing at the Cart instance.


Common mistakes

Mistake 1: forgetting super()

// ❌ Wrong
class Child extends Parent {
    constructor(name, age) {
        // Missing super()!
        this.age = age;  // ReferenceError
    }
}
// ✅ Correct
class Child extends Parent {
    constructor(name, age) {
        super(name);
        this.age = age;
    }
}

This isn’t a stylistic lint rule — it’s enforced by the engine. In a derived class, this doesn’t exist yet when the constructor body starts running; it’s super() that actually creates the object (by running the parent constructor) and binds it to this. Touching this before that call — even just reading it — throws a ReferenceError, which is the engine’s way of guaranteeing a subclass instance is never left in a state where the parent’s setup never ran.

Mistake 2: arrow functions as methods (sometimes the right call, sometimes not)

// ⚠️ Arrow as a class field — creates one function per instance;
// correct choice specifically when this method will be detached/passed as a callback (see Section 6)
class Person {
    constructor(name) {
        this.name = name;
    }
    
    greet = () => {
        console.log(`Hello, ${this.name}!`);
    }
}
// ✅ Ordinary method — one shared function on the prototype; the default choice
class Person {
    constructor(name) {
        this.name = name;
    }
    
    greet() {
        console.log(`Hello, ${this.name}!`);
    }
}

Neither version is universally “wrong” — Section 6 covers exactly when the arrow-field version earns its per-instance allocation cost (methods that get detached and passed around as callbacks) versus when the ordinary, prototype-shared version is simply the more efficient default (methods that are always called as obj.method() directly).

Mistake 3: calling without new

class Person {
    constructor(name) {
        this.name = name;
    }
}
// ❌ No new
// const person = Person("Alice");  // TypeError
// ✅ Use new
const person = new Person("Alice");

Unlike an ordinary function (which happily runs with this bound to undefined or the global object if called without new), a function defined via class carries an internal flag the engine checks on every call — attempting to invoke it without new throws a TypeError immediately, rather than letting the mistake silently produce a broken object or pollute global scope. This is one of the safety rails mentioned back in the desugaring section: it’s behavior the manual ES5 function + .prototype pattern never had.


Practice problems

Problem 1: Stack class

class Stack {
    constructor() {
        this.items = [];
    }
    
    push(item) {
        this.items.push(item);
    }
    
    pop() {
        if (this.isEmpty()) {
            throw new Error("Stack is empty");
        }
        return this.items.pop();
    }
    
    peek() {
        if (this.isEmpty()) {
            return null;
        }
        return this.items[this.items.length - 1];
    }
    
    isEmpty() {
        return this.items.length === 0;
    }
    
    get size() {
        return this.items.length;
    }
}
// Tests
const stack = new Stack();
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack.peek());  // 3
console.log(stack.pop());   // 3
console.log(stack.size);    // 2

get size() here is a good small example of when a getter beats a plain method: stack.size reads naturally as a property (like array.length), and there’s no meaningful “operation” being performed beyond returning a derived number, so forcing callers to write stack.size() would be needless ceremony. Contrast that with pop(), which mutates state and can throw — a method call, not a property read, correctly signals “this does something,” which is exactly the getter/method distinction to keep in mind when designing a class’s public surface.

Problem 2: Timer class

class Timer {
    constructor() {
        this.startTime = null;
        this.elapsed = 0;
        this.running = false;
    }
    
    start() {
        if (this.running) return;
        
        this.running = true;
        this.startTime = Date.now() - this.elapsed;
    }
    
    stop() {
        if (!this.running) return;
        
        this.running = false;
        this.elapsed = Date.now() - this.startTime;
    }
    
    reset() {
        this.startTime = null;
        this.elapsed = 0;
        this.running = false;
    }
    
    getTime() {
        if (this.running) {
            return Date.now() - this.startTime;
        }
        return this.elapsed;
    }
    
    toString() {
        const ms = this.getTime();
        const seconds = Math.floor(ms / 1000);
        const minutes = Math.floor(seconds / 60);
        const remainingSeconds = seconds % 60;
        
        return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
    }
}
// Test
const timer = new Timer();
timer.start();
setTimeout(() => {
    console.log(timer.toString());  // 0:02
    timer.stop();
}, 2000);

Two things worth flagging in this one. First, start() calculates startTime as Date.now() - this.elapsed, not simply Date.now() — that’s what makes stop() followed by another start() resume correctly instead of restarting the clock from zero; if you trace through a start → stop → start sequence by hand, this “shift the epoch back by the already-elapsed time” trick is the key line to understand. Second, the final setTimeout call is passed an inline arrow function, () => { ... }, rather than timer.toString directly — which is exactly the Section 6 pitfall avoided correctly: passing timer.toString as the bare callback would have detached it from timer, and calling this.getTime() inside toString() would have failed with this undefined.


Frequently Asked Questions (FAQ)

Q. Are JavaScript classes “real” classes, like in Java or C++?

A. No — they’re syntactic sugar over JavaScript’s prototype-based object model. A class declaration still produces an ordinary function with a .prototype object, and instances still resolve properties and methods by walking the [[Prototype]] chain. What class adds is safety and ergonomics (no calling without new, non-enumerable methods, always-strict-mode, cleaner extends/super syntax), not a different underlying mechanism.

Q. Why does this become undefined inside a method I passed as a callback?

A. Because this is bound by how a function is called, not by where it was defined. obj.method extracts only the function value — the link back to obj isn’t carried along when you pass it to setTimeout, an event listener, or Array.prototype.map. Fix it with .bind(this), an arrow-function class field, or by wrapping the call in an arrow function at the call site — see the dedicated walkthrough in Section 6 above.

Q. What’s actually different between #field and the old _field convention?

A. Enforcement. _field is a naming convention only — obj._field is still a completely ordinary public property that any caller can read or write. #field is enforced by the JavaScript engine itself: accessing obj.#field from outside the class body is a SyntaxError, not just discouraged. It replaces two older genuinely-private workarounds (constructor closures, and WeakMap keyed by instance) without their downsides — no per-instance function allocation, no separate WeakMap bookkeeping to maintain.