Skip to main content
Advertisement

5.4 Advanced Classes

Explore advanced class features including inheritance, abstract classes, private fields, and the mixin pattern.


extends and super(): Implementing Inheritance

Use extends to inherit from a parent class, and super() to call the parent constructor.

class Shape {
constructor(color = "black") {
if (new.target === Shape) {
throw new Error("Shape cannot be instantiated directly");
}
this.color = color;
}

area() {
throw new Error("area() must be implemented by a subclass");
}

perimeter() {
throw new Error("perimeter() must be implemented by a subclass");
}

toString() {
return `${this.constructor.name}[color=${this.color}, area=${this.area().toFixed(2)}]`;
}
}

class Circle extends Shape {
#radius;

constructor(radius, color) {
super(color); // must call super() first
this.#radius = radius;
}

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

area() { return Math.PI * this.#radius ** 2; }
perimeter() { return 2 * Math.PI * this.#radius; }
}

class Rectangle extends Shape {
constructor(width, height, color) {
super(color);
this.width = width;
this.height = height;
}

area() { return this.width * this.height; }
perimeter() { return 2 * (this.width + this.height); }
}

class Square extends Rectangle {
constructor(side, color) {
super(side, side, color); // calls Rectangle's constructor
}

// inherits area() and perimeter() from Rectangle
}

const circle = new Circle(5, "red");
const rect = new Rectangle(4, 6, "blue");
const sq = new Square(3, "green");

console.log(`${circle}`); // "Circle[color=red, area=78.54]"
console.log(`${rect}`); // "Rectangle[color=blue, area=24.00]"
console.log(`${sq}`); // "Square[color=green, area=9.00]"

console.log(sq instanceof Square); // true
console.log(sq instanceof Rectangle); // true
console.log(sq instanceof Shape); // true

super.method() vs super()

class Logger {
log(message) {
return `[LOG] ${message}`;
}

error(message) {
return `[ERROR] ${message}`;
}
}

class TimestampLogger extends Logger {
log(message) {
const ts = new Date().toTimeString().slice(0, 8);
// super.method(): calls the parent's method
return `${ts} ${super.log(message)}`;
}

error(message) {
return `${super.error(message)} (${new Date().toISOString()})`;
}
}

class FilterLogger extends TimestampLogger {
#minLevel;

constructor(minLevel = "log") {
super(); // super(): calls the parent constructor
this.#minLevel = minLevel;
}

log(message) {
if (this.#minLevel === "error") return; // filter out
return super.log(message); // delegate to parent
}
}

const logger = new TimestampLogger();
console.log(logger.log("Server started"));
// "12:30:00 [LOG] Server started"

const filtered = new FilterLogger("error");
console.log(filtered.log("ignored")); // undefined (filtered out)
console.log(filtered.error("critical error")); // error message

Abstract Class Pattern

JavaScript doesn't have abstract classes at the language level, but new.target allows you to implement the pattern.

class AbstractRepository {
constructor() {
if (new.target === AbstractRepository) {
throw new TypeError("AbstractRepository cannot be instantiated directly");
}

// Enforce abstract method implementation
const abstractMethods = ["findById", "findAll", "save", "delete"];
for (const method of abstractMethods) {
if (typeof this[method] !== "function") {
throw new TypeError(
`${new.target.name} must implement the '${method}' method`
);
}
}
}

// Common utility methods (inherited by subclasses)
async findOrFail(id) {
const entity = await this.findById(id);
if (!entity) {
throw new Error(`Entity with ID ${id} not found`);
}
return entity;
}

async exists(id) {
const entity = await this.findById(id);
return entity !== null;
}
}

// Concrete implementation class
class InMemoryUserRepository extends AbstractRepository {
#store = new Map();
#nextId = 1;

async findById(id) {
return this.#store.get(id) ?? null;
}

async findAll() {
return [...this.#store.values()];
}

async save(user) {
if (!user.id) {
user.id = this.#nextId++;
}
this.#store.set(user.id, { ...user });
return user;
}

async delete(id) {
return this.#store.delete(id);
}
}

const repo = new InMemoryUserRepository();
(async () => {
await repo.save({ name: "Alice", email: "alice@example.com" });
await repo.save({ name: "Bob", email: "bob@example.com" });

const users = await repo.findAll();
console.log(users.length); // 2

const user = await repo.findById(1);
console.log(user.name); // "Alice"

try {
await repo.findOrFail(99);
} catch (e) {
console.log(e.message); // "Entity with ID 99 not found"
}
})();

Private Fields and Methods (#)

Fields and methods declared with # are completely inaccessible from outside the class.

class LinkedList {
// Private class fields
#head = null;
#tail = null;
#size = 0;

// Private methods
#createNode(value) {
return { value, next: null, prev: null };
}

#validateIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= this.#size) {
throw new RangeError(`Invalid index: ${index}`);
}
}

// Public API
push(value) {
const node = this.#createNode(value); // calling private method
if (this.#head === null) {
this.#head = this.#tail = node;
} else {
node.prev = this.#tail;
this.#tail.next = node;
this.#tail = node;
}
this.#size++;
return this;
}

pop() {
if (this.#size === 0) return undefined;
const value = this.#tail.value;
this.#tail = this.#tail.prev;
if (this.#tail) {
this.#tail.next = null;
} else {
this.#head = null;
}
this.#size--;
return value;
}

get(index) {
this.#validateIndex(index);
let current = this.#head;
for (let i = 0; i < index; i++) {
current = current.next;
}
return current.value;
}

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

*[Symbol.iterator]() {
let current = this.#head;
while (current) {
yield current.value;
current = current.next;
}
}

toArray() { return [...this]; }
}

const list = new LinkedList();
list.push(1).push(2).push(3).push(4);

console.log(list.size); // 4
console.log(list.get(2)); // 3
console.log(list.pop()); // 4
console.log(list.toArray()); // [1, 2, 3]

// External access is impossible
// list.#head // SyntaxError
// list.#size // SyntaxError
// list.#createNode(5) // SyntaxError

// Check for private field existence (using in operator)
const hasPrivate = (obj) => #head in obj;
console.log(hasPrivate(list)); // true

Protected Pattern: _prefix Convention

JavaScript has no protected keyword. The _ prefix convention indicates "for subclass use only".

class EventEmitter {
// _prefix: conventionally protected (signals external code to not use directly)
_listeners = new Map();

on(event, listener) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(listener);
return this;
}

off(event, listener) {
this._listeners.get(event)?.delete(listener);
return this;
}

_emit(event, ...args) {
this._listeners.get(event)?.forEach(fn => fn(...args));
}
}

class Store extends EventEmitter {
#state;

constructor(initialState) {
super();
this.#state = initialState;
}

getState() { return { ...this.#state }; }

setState(updates) {
const prevState = this.#state;
this.#state = { ...this.#state, ...updates };
// _emit is protected: for use in subclasses
this._emit("change", this.#state, prevState);
return this;
}
}

const store = new Store({ count: 0, name: "App" });
store.on("change", (newState, prevState) => {
console.log(`State changed: count ${prevState.count}${newState.count}`);
});

store.setState({ count: 1 }); // "State changed: count 0 → 1"
store.setState({ count: 5 }); // "State changed: count 1 → 5"

Mixin Pattern

An alternative to multiple inheritance that overcomes JavaScript's single inheritance limitation.

// Mixin: a function that takes a base class and returns an extended class
const Serializable = (Base) => class extends Base {
serialize() {
return JSON.stringify(this);
}

static deserialize(json) {
return Object.assign(new this(), JSON.parse(json));
}
};

const Validatable = (Base) => class extends Base {
#validators = {};

addValidator(field, fn) {
this.#validators[field] = fn;
return this;
}

validate() {
const errors = {};
for (const [field, fn] of Object.entries(this.#validators)) {
const error = fn(this[field]);
if (error) errors[field] = error;
}
return { valid: Object.keys(errors).length === 0, errors };
}
};

const Timestamped = (Base) => class extends Base {
createdAt = new Date().toISOString();
updatedAt = new Date().toISOString();

touch() {
this.updatedAt = new Date().toISOString();
return this;
}
};

// Combine multiple mixins
class UserModel extends Serializable(Validatable(Timestamped(class {}))) {
constructor(name, email, age) {
super();
this.name = name;
this.email = email;
this.age = age;

this
.addValidator("name", v => !v?.trim() ? "Name is required" : null)
.addValidator("email", v => !v?.includes("@") ? "Valid email required" : null)
.addValidator("age", v => (v < 0 || v > 150) ? "Valid age required" : null);
}
}

const user = new UserModel("Alice", "alice@example.com", 30);
console.log(user.validate()); // { valid: true, errors: {} }

const invalid = new UserModel("", "notanemail", -1);
const { valid, errors } = invalid.validate();
console.log(valid); // false
console.log(errors); // { name: "Name is required", email: "Valid email required", age: "Valid age required" }

const json = user.serialize();
console.log(typeof json); // "string"
console.log(user.createdAt); // ISO date string

Override toString, Symbol.toPrimitive

class Vector2D {
constructor(x, y) {
this.x = x;
this.y = y;
}

// String representation
toString() {
return `Vector2D(${this.x}, ${this.y})`;
}

// Type-aware conversion
[Symbol.toPrimitive](hint) {
switch (hint) {
case "number":
return Math.sqrt(this.x ** 2 + this.y ** 2); // magnitude
case "string":
return this.toString();
default:
return this.x + this.y; // default
}
}

// Vector operations
add(other) {
return new Vector2D(this.x + other.x, this.y + other.y);
}

scale(factor) {
return new Vector2D(this.x * factor, this.y * factor);
}

get magnitude() {
return Math.sqrt(this.x ** 2 + this.y ** 2);
}

normalize() {
const mag = this.magnitude;
return new Vector2D(this.x / mag, this.y / mag);
}
}

const v1 = new Vector2D(3, 4);
const v2 = new Vector2D(1, 2);

console.log(`${v1}`); // "Vector2D(3, 4)"
console.log(+v1); // 5 (magnitude, Symbol.toPrimitive "number")
console.log(v1 > v2); // true (5 > ~2.24)
console.log(v1.add(v2).toString()); // "Vector2D(4, 6)"

// Customize JSON serialization
class DateRange {
constructor(start, end) {
this.start = new Date(start);
this.end = new Date(end);
}

toJSON() {
return {
start: this.start.toISOString(),
end: this.end.toISOString(),
days: Math.ceil((this.end - this.start) / (1000 * 60 * 60 * 24)),
};
}

toString() {
return `${this.start.toLocaleDateString()} ~ ${this.end.toLocaleDateString()}`;
}
}

const range = new DateRange("2024-01-01", "2024-01-31");
console.log(JSON.stringify({ trip: range }));
// {"trip":{"start":"2024-01-01T00:00:00.000Z","end":"2024-01-31T00:00:00.000Z","days":30}}

Pro Tips

Tip 1: Factory function returning a class

function createModel(tableName, schema) {
return class Model {
static table = tableName;
static #schema = schema;

static validate(data) {
const errors = {};
for (const [field, rules] of Object.entries(Model.#schema)) {
if (rules.required && !data[field]) {
errors[field] = `${field} is required`;
}
if (rules.type && typeof data[field] !== rules.type) {
errors[field] = `${field} must be of type ${rules.type}`;
}
}
return errors;
}

constructor(data) {
const errors = Model.validate(data);
if (Object.keys(errors).length > 0) {
throw new Error(Object.values(errors).join(", "));
}
Object.assign(this, data);
}
};
}

const UserModel = createModel("users", {
name: { required: true, type: "string" },
age: { required: true, type: "number" },
});

const user = new UserModel({ name: "Alice", age: 30 });
console.log(user.name); // "Alice"
console.log(UserModel.table); // "users"

Tip 2: Correct use of super in the inheritance chain

class A {
method() { return "A"; }
static staticMethod() { return "A.static"; }
}

class B extends A {
method() { return `B → ${super.method()}`; }
static staticMethod() { return `B.static → ${super.staticMethod()}`; }
}

class C extends B {
method() { return `C → ${super.method()}`; }
}

const c = new C();
console.log(c.method()); // "C → B → A"
console.log(B.staticMethod()); // "B.static → A.static"

Tip 3: instanceof and Symbol.hasInstance

class EvenNumber {
static [Symbol.hasInstance](instance) {
return typeof instance === "number" && instance % 2 === 0;
}
}

console.log(2 instanceof EvenNumber); // true
console.log(3 instanceof EvenNumber); // false
console.log(100 instanceof EvenNumber); // true

// Type checking utility
class TypeChecker {
static [Symbol.hasInstance](instance) {
return this.check(instance);
}
}

class NonEmptyString extends TypeChecker {
static check(value) {
return typeof value === "string" && value.length > 0;
}
}

console.log("hello" instanceof NonEmptyString); // true
console.log("" instanceof NonEmptyString); // false
console.log(42 instanceof NonEmptyString); // false
Advertisement