JavaScript Classes

ES6 classes bring clean object-oriented syntax to JavaScript. Learn constructors, instance and static members, private fields, inheritance, and the patterns used in modern JavaScript frameworks.

Intermediate 12 min read 12 examples

Class Basics

A class is a blueprint for creating objects. The constructor is called automatically when you use new.

JavaScript
class User {
    constructor(name, email) {
        this.name  = name;  // instance property
        this.email = email;
    }

    greet() {
        return `Hello, I am ${this.name}`;
    }

    toString() {
        return `User(${this.name}, ${this.email})`;
    }
}

const alice = new User("Alice", "alice@example.com");
console.log(alice.greet());   // "Hello, I am Alice"
console.log(alice.name);      // "Alice"
console.log(`${alice}`);      // "User(Alice, alice@example.com)"
console.log(alice instanceof User); // true

// Class expression (less common)
const Animal = class {
    constructor(name) { this.name = name; }
};

// Classes are NOT hoisted like function declarations
// const x = new MyClass(); // ReferenceError
class MyClass {}
const x = new MyClass(); // OK after the declaration

Methods and Properties

JavaScript
class Counter {
    // Class field: own property on each instance (ES2022)
    count = 0;
    step  = 1;

    constructor(start = 0, step = 1) {
        this.count = start;
        this.step  = step;
    }

    // Instance method (on prototype - shared)
    increment() {
        this.count += this.step;
        return this; // return this for method chaining
    }

    decrement() {
        this.count -= this.step;
        return this;
    }

    reset() {
        this.count = 0;
        return this;
    }

    value() {
        return this.count;
    }

    // Arrow function as class field (own property - not on prototype)
    // Useful when passing as callback - always has correct 'this'
    handleClick = () => {
        this.increment();
        console.log(this.count);
    }
}

const c = new Counter(0, 2);
c.increment().increment().decrement();
console.log(c.value()); // 2 (0 +2 +2 -2)

// Prototype vs instance: methods are shared, fields are per-instance
const a = new Counter();
const b = new Counter();
console.log(a.increment === b.increment); // true (shared method)
console.log(a.handleClick === b.handleClick); // false (per-instance field)

Getters and Setters

Getters and setters allow computed properties and validation on assignment. They are accessed like regular properties, not called like methods.

JavaScript
class Temperature {
    constructor(celsius) {
        this._celsius = celsius;
    }

    // Getter: accessed as a property, not a method call
    get fahrenheit() {
        return this._celsius * 9 / 5 + 32;
    }

    get celsius() {
        return this._celsius;
    }

    // Setter: validation on assignment
    set celsius(value) {
        if (value < -273.15) {
            throw new RangeError("Temperature below absolute zero");
        }
        this._celsius = value;
    }

    get description() {
        if (this._celsius < 0)   return "freezing";
        if (this._celsius < 20)  return "cold";
        if (this._celsius < 30)  return "warm";
        return "hot";
    }
}

const temp = new Temperature(100);
console.log(temp.fahrenheit);    // 212 (getter - no parentheses!)
console.log(temp.description);   // "hot"

temp.celsius = 20;               // uses setter
console.log(temp.celsius);       // 20
console.log(temp.fahrenheit);    // 68

// temp.celsius = -300;          // throws RangeError

Static Members

static methods and properties belong to the class itself, not to any instance. They cannot access this (the instance) - this inside a static method refers to the class.

JavaScript
class MathHelper {
    static PI = 3.14159265358979;

    static square(n)  { return n ** 2; }
    static cube(n)    { return n ** 3; }
    static clamp(n, min, max) { return Math.min(Math.max(n, min), max); }
}

// Call on the class, not an instance
console.log(MathHelper.PI);           // 3.14159...
console.log(MathHelper.square(5));    // 25
console.log(MathHelper.clamp(15, 0, 10)); // 10

// const h = new MathHelper();
// h.square(5); // TypeError: h.square is not a function

// Factory method pattern (static that returns instances)
class User {
    constructor(name, role) {
        this.name = name;
        this.role = role;
    }

    static createAdmin(name) {
        return new User(name, "admin");
    }

    static createGuest() {
        return new User("Guest", "guest");
    }
}

const admin = User.createAdmin("Alice");
const guest = User.createGuest();
console.log(admin.role); // "admin"
console.log(guest.name); // "Guest"

Private Fields (#)

Private fields (prefixed with #) are truly encapsulated - inaccessible from outside the class. They must be declared at the class body level.

JavaScript
class BankAccount {
    #balance = 0;            // private field
    #owner;

    constructor(owner, initialBalance = 0) {
        this.#owner   = owner;
        this.#balance = initialBalance;
    }

    deposit(amount) {
        if (amount <= 0) throw new Error("Amount must be positive");
        this.#balance += amount;
        return this;
    }

    withdraw(amount) {
        if (amount > this.#balance) throw new Error("Insufficient funds");
        this.#balance -= amount;
        return this;
    }

    get balance() { return this.#balance; }  // expose via getter
    get owner()   { return this.#owner; }

    toString() {
        return `${this.#owner}'s account: $${this.#balance}`;
    }
}

const account = new BankAccount("Alice", 1000);
account.deposit(500).withdraw(200);
console.log(account.balance); // 1300
console.log(`${account}`);    // "Alice's account: $1300"

// console.log(account.#balance); // SyntaxError - truly private!
// account.#balance = 9999;       // SyntaxError

Inheritance (extends and super)

JavaScript
class Animal {
    constructor(name) {
        this.name = name;
    }

    speak() {
        return `${this.name} makes a sound.`;
    }

    toString() {
        return `Animal(${this.name})`;
    }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name);    // MUST call super() before accessing 'this'
        this.breed = breed;
    }

    // Override parent method
    speak() {
        return `${this.name} barks!`;
    }

    // Call parent method with super.method()
    describe() {
        return `${super.speak()} It is a ${this.breed}.`;
    }
}

class GoldenRetriever extends Dog {
    constructor(name) {
        super(name, "Golden Retriever"); // chain up
    }

    fetch() {
        return `${this.name} fetches the ball!`;
    }
}

const buddy = new GoldenRetriever("Buddy");
console.log(buddy.speak());    // "Buddy barks!"
console.log(buddy.describe()); // "Buddy makes a sound. It is a Golden Retriever."
console.log(buddy.fetch());    // "Buddy fetches the ball!"
console.log(buddy instanceof GoldenRetriever); // true
console.log(buddy instanceof Dog);             // true
console.log(buddy instanceof Animal);          // true

Method Overriding

JavaScript
class Shape {
    constructor(color = "black") {
        this.color = color;
    }
    area()      { return 0; }
    perimeter() { return 0; }
    describe() {
        return `${this.constructor.name}: area=${this.area().toFixed(2)}`;
    }
}

class Circle extends Shape {
    constructor(radius, color) {
        super(color);
        this.radius = radius;
    }
    area()      { return Math.PI * this.radius ** 2; }
    perimeter() { return 2 * Math.PI * this.radius; }
}

class Rectangle extends Shape {
    constructor(w, h, color) {
        super(color);
        this.width = w;
        this.height = h;
    }
    area()      { return this.width * this.height; }
    perimeter() { return 2 * (this.width + this.height); }
}

const shapes = [
    new Circle(5),
    new Rectangle(4, 6),
    new Circle(3, "red"),
];

// Polymorphism: same method, different behavior
shapes.forEach(s => console.log(s.describe()));
// "Circle: area=78.54"
// "Rectangle: area=24.00"
// "Circle: area=28.27"

Class Patterns

JavaScript
// Mixin pattern (mix behaviors without multiple inheritance)
const Serializable = (Base) => class extends Base {
    toJSON() { return JSON.stringify(this); }
    static fromJSON(json) { return Object.assign(new this(), JSON.parse(json)); }
};

const Validatable = (Base) => class extends Base {
    validate() {
        for (const [key, value] of Object.entries(this)) {
            if (value === null || value === undefined) {
                throw new Error(`${key} is required`);
            }
        }
        return true;
    }
};

class User {
    constructor(name, email) {
        this.name  = name;
        this.email = email;
    }
}

class RichUser extends Serializable(Validatable(User)) {}

const user = new RichUser("Alice", "alice@example.com");
user.validate();             // OK
console.log(user.toJSON());  // '{"name":"Alice","email":"alice@example.com"}'

Frequently Asked Questions

Methods defined inside a class body (greet() { ... }) are added to the class prototype and shared across all instances. Class fields (name = "default") are own properties created on each individual instance in the constructor. Arrow function class fields (handleClick = () => {}) are also per-instance, which is why they do not lose this context when passed as callbacks.

super() in a constructor calls the parent class constructor - it must be called before accessing this in a derived class constructor. super.method() calls a method on the parent class. You can also use super to call overridden methods from the parent while adding new behavior in the child class.

Private fields (prefixed with #) are truly private - they cannot be accessed or even detected from outside the class. Unlike naming conventions like _private, the enforcement is at the language level. Private fields must be declared at the class body level before use. They are accessible within the class and its methods, but not on instances from external code. Added in ES2022.

Use static for methods that belong to the class itself, not to any specific instance: factory methods (User.create(data)), utility functions (Math.round()), or methods that do not use instance state. Instance methods (user.save()) operate on individual instances and access this. A good rule: if a method does not reference this, it is likely a candidate for static.