Skip to main content
Advertisement

5.3 ES6 Classes

The class syntax introduced in ES6 expresses prototype-based inheritance in a cleaner, more readable way. Internally, it still operates on prototypes.


Class Declaration and Expression

// Class declaration
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}

// Named class expression
const Animal = class AnimalClass {
constructor(name) {
this.name = name;
}
// AnimalClass is only accessible inside the class
};

// Anonymous class expression
const Vehicle = class {
constructor(make, model) {
this.make = make;
this.model = model;
}
};

// Classes are functions
console.log(typeof Person); // "function"
console.log(typeof Animal); // "function"

// Class declarations are hoisted but TDZ applies (unlike function declarations)
// const p = new Person2("Alice"); // ReferenceError (TDZ)
// class Person2 {}

const alice = new Person("Alice", 30);
const car = new Vehicle("Hyundai", "Elantra");

console.log(alice.name); // "Alice"
console.log(car.make); // "Hyundai"

constructor: The Constructor Method

constructor is automatically called when creating an instance with new. A class can have only one.

class Product {
constructor(name, price, category = "General") {
// Initialize instance properties
this.name = name;
this.price = price;
this.category = category;
this.createdAt = new Date();
this.id = Product._nextId++;

// Validation
if (price < 0) throw new RangeError("Price must be 0 or greater");
if (!name?.trim()) throw new TypeError("Product name is required");
}
}
Product._nextId = 1; // static counter

const laptop = new Product("Laptop", 1200, "Electronics");
const phone = new Product("Smartphone", 800, "Electronics");

console.log(laptop.id); // 1
console.log(phone.id); // 2
console.log(laptop.createdAt instanceof Date); // true

// Omitting constructor uses a default constructor
class SimpleClass {
// implicit: constructor() {}
}
const s = new SimpleClass(); // works fine

Instance Methods

Methods defined in the class body are added to prototype.

class BankAccount {
constructor(owner, balance = 0) {
this.owner = owner;
this._balance = balance;
}

// Instance methods — added to BankAccount.prototype
deposit(amount) {
if (amount <= 0) throw new Error("Deposit amount must be greater than 0");
this._balance += amount;
return this; // support method chaining
}

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

getBalance() {
return this._balance;
}

toString() {
return `BankAccount[${this.owner}: $${this._balance.toLocaleString()}]`;
}
}

// Methods are on prototype (shared by all instances)
console.log(Object.getOwnPropertyNames(BankAccount.prototype));
// ["constructor", "deposit", "withdraw", "getBalance", "toString"]

const account = new BankAccount("Alice", 10000);
account.deposit(5000).deposit(3000).withdraw(2000); // chaining
console.log(account.getBalance()); // 16000
console.log(`${account}`); // "BankAccount[Alice: $16,000]"

Static Methods

Methods defined with static are called on the class itself, not on instances.

class MathUtils {
// Static method: called as MathUtils.clamp(...)
static clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}

static lerp(a, b, t) {
return a + (b - a) * t;
}

static randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}

static average(...numbers) {
return numbers.reduce((a, b) => a + b, 0) / numbers.length;
}
}

console.log(MathUtils.clamp(15, 0, 10)); // 10
console.log(MathUtils.lerp(0, 100, 0.5)); // 50
console.log(MathUtils.average(1, 2, 3, 4, 5)); // 3

// Factory method pattern
class Color {
constructor(r, g, b) {
this.r = r; this.g = g; this.b = b;
}

// Static factory methods
static fromHex(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return new Color(r, g, b);
}

static fromHSL(h, s, l) {
// HSL to RGB conversion (simplified)
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r, g, b;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return new Color(
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255)
);
}

toHex() {
return `#${[this.r, this.g, this.b].map(v => v.toString(16).padStart(2, "0")).join("")}`;
}

toString() {
return `rgb(${this.r}, ${this.g}, ${this.b})`;
}
}

const red = Color.fromHex("#ff0000");
const blue = Color.fromHSL(240, 1, 0.5);

console.log(`${red}`); // "rgb(255, 0, 0)"
console.log(blue.toHex()); // "#0000ff"

getter / setter in class

class Temperature {
#celsius;

constructor(celsius) {
this.celsius = celsius; // initializes via setter
}

get celsius() {
return this.#celsius;
}

set celsius(value) {
if (typeof value !== "number") {
throw new TypeError("Temperature must be a number");
}
if (value < -273.15) {
throw new RangeError("Cannot go below absolute zero");
}
this.#celsius = value;
}

get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}

set fahrenheit(value) {
this.celsius = (value - 32) * 5 / 9;
}

get kelvin() {
return this.#celsius + 273.15;
}

get description() {
if (this.#celsius < 0) return "below freezing";
if (this.#celsius < 10) return "very cold";
if (this.#celsius < 20) return "chilly";
if (this.#celsius < 30) return "warm";
return "hot";
}
}

const temp = new Temperature(25);
console.log(temp.celsius); // 25
console.log(temp.fahrenheit); // 77
console.log(temp.kelvin); // 298.15
console.log(temp.description); // "warm"

temp.fahrenheit = 32;
console.log(temp.celsius); // 0
console.log(temp.description); // "below freezing"

Class Fields

Standardized in ES2022, class fields initialize instance properties directly.

class Counter {
// Public class fields (instance initialization)
count = 0;
label = "Counter";

// Class field arrow functions (fixed this binding)
increment = () => {
this.count++;
return this;
};

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

// Static class fields
static defaultStep = 1;
static instances = 0;

constructor(initialValue = 0, label = "Counter") {
this.count = initialValue;
this.label = label;
Counter.instances++;
}

toString() {
return `${this.label}: ${this.count}`;
}
}

const c1 = new Counter(10, "Score");
const c2 = new Counter(0, "Mistakes");

c1.increment().increment().increment();
c2.decrement();

console.log(`${c1}`); // "Score: 13"
console.log(`${c2}`); // "Mistakes: -1"
console.log(Counter.instances); // 2

// Class field arrow functions preserve this after destructuring
const { increment } = c1;
increment();
console.log(c1.count); // 14 (this remains c1)

typeof class === 'function'

Confirming that a class is syntactic sugar over a function.

class Example {}

console.log(typeof Example); // "function"
console.log(Example.prototype.constructor === Example); // true

// Differences between class and regular function
function OldStyle(x) { this.x = x; }
class NewStyle { constructor(x) { this.x = x; } }

// 1. Cannot call without new (class)
try {
NewStyle(1); // TypeError: Class constructor cannot be invoked without 'new'
} catch (e) {
console.log(e.message);
}

// 2. TDZ applies to class declarations (not hoisted to usable state)
// 3. Class body always runs in strict mode
// 4. Class methods are non-enumerable (excluded from for...in)

console.log(
Object.getOwnPropertyDescriptor(NewStyle.prototype, "constructor")?.enumerable
); // false (non-enumerable)

Practical Example: Type-Safe Collection Class

class TypedCollection {
#items = [];
#type;

constructor(type) {
if (typeof type !== "function") {
throw new TypeError("A type constructor function is required");
}
this.#type = type;
}

add(item) {
if (!(item instanceof this.#type)) {
throw new TypeError(`Only ${this.#type.name} instances can be added`);
}
this.#items.push(item);
return this;
}

get(index) {
return this.#items[index] ?? null;
}

remove(item) {
const idx = this.#items.indexOf(item);
if (idx !== -1) this.#items.splice(idx, 1);
return this;
}

get size() { return this.#items.length; }

*[Symbol.iterator]() {
yield* this.#items;
}

static of(type, ...items) {
const collection = new TypedCollection(type);
items.forEach(item => collection.add(item));
return collection;
}
}

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

const users = TypedCollection.of(
User,
new User("Alice", "alice@example.com"),
new User("Bob", "bob@example.com"),
);

users.add(new User("Carol", "carol@example.com"));

try {
users.add({ name: "Invalid" }); // TypeError!
} catch (e) {
console.log(e.message); // "Only User instances can be added"
}

console.log(users.size); // 3

for (const user of users) {
console.log(user.name);
}
// Alice, Bob, Carol

Pro Tips

Tip 1: Static initialization blocks (ES2022)

class DatabaseConfig {
static host;
static port;
static connectionString;

// Complex static initialization logic
static {
const env = typeof process !== "undefined" ? process.env : {};
DatabaseConfig.host = env.DB_HOST ?? "localhost";
DatabaseConfig.port = parseInt(env.DB_PORT ?? "5432");
DatabaseConfig.connectionString =
`postgresql://${DatabaseConfig.host}:${DatabaseConfig.port}/mydb`;
}
}

console.log(DatabaseConfig.connectionString);
// "postgresql://localhost:5432/mydb"

Tip 2: Class decorator pattern (TypeScript/Babel style)

function singleton(Class) {
let instance = null;
return class extends Class {
constructor(...args) {
if (instance) return instance;
super(...args);
instance = this;
}
static getInstance(...args) {
if (!instance) new this(...args);
return instance;
}
};
}

const SingletonLogger = singleton(class Logger {
constructor(name) {
this.name = name;
this.logs = [];
}
log(msg) { this.logs.push(msg); }
});

const l1 = new SingletonLogger("App");
const l2 = new SingletonLogger("Other");

l1.log("First");
console.log(l2.logs); // ["First"] — same instance!
console.log(l1 === l2); // true

Tip 3: Override toString, valueOf, Symbol.toPrimitive

class Money {
constructor(amount, currency = "USD") {
this.amount = amount;
this.currency = currency;
}

toString() { return `${this.amount.toLocaleString()} ${this.currency}`; }
valueOf() { return this.amount; } // used in numeric operations

[Symbol.toPrimitive](hint) {
if (hint === "number") return this.amount;
if (hint === "string") return this.toString();
return this.amount; // default
}
}

const price = new Money(50);
const tax = new Money(5);

console.log(`Price: ${price}`); // "Price: 50 USD"
console.log(price + tax); // 55 (valueOf used)
console.log(price > 30); // true
console.log(`Total: ${price + tax}`); // "Total: 55"
Advertisement