JavaScript (ES2026) Module 11 – Prototypes and classes
Composition, mixins and OOP design in JavaScript
How to compose objects from small collaborators instead of deep class trees, write mixins with Object.assign or subclass factories, and apply OOP ideas.
What you will learn
- Compose behaviour from small objects and functions instead of deep inheritance
- Write mixins with Object.assign and with subclass factories, and predict where they conflict
- Pass collaborators such as clocks and senders into objects so that tests can use fakes
- Apply encapsulation, polymorphism and SOLID-style ideas the way JavaScript expresses them
Before you start
What you’ll learn · Before you start
What you will learn
- Compose behaviour from small objects and functions instead of deep inheritance
- Write mixins with Object.assign and with subclass factories, and predict where they conflict
- Pass collaborators such as clocks and senders into objects so that tests can use fakes
- Apply encapsulation, polymorphism and SOLID-style ideas the way JavaScript expresses them
Before you start
On this page
Inheritance says what an object is; composition says what it has. In JavaScript, deep class hierarchies age
badly: a change to a base class reaches every subclass, and every new combination of features needs another subclass.
Composition builds an object from small collaborators that it holds and calls, such as a sender, a clock or a log,
so that each part can be swapped, reused and replaced by a fake in a test. Mixins give a set of methods to
classes that inheritance does not relate, either by copying methods onto a prototype with Object.assign or with a
subclass factory, (Base) => class extends Base { … }. And because JavaScript looks methods up when they are
called, any object with the right methods works where another is expected, which is polymorphism without interfaces.
This lesson turns a class hierarchy into composed parts, writes both kinds of mixin with the traps of each, and shows
how encapsulation, polymorphism and the usual design principles look in idiomatic JavaScript.
When inheritance stops fitting
Here are notifications built the inheritance way, one feature per level:
// Notifications built by inheritance: each new feature becomes another level of subclass.
const outbox = [];
class Notifier {
notify(user, text) {
this.deliver(user, text);
}
deliver() {
throw new Error('a subclass must implement deliver');
}
}
class SmsNotifier extends Notifier {
deliver(user, text) {
outbox.push(`sms to ${user.phone}: ${text}`);
}
}
class RetryingSmsNotifier extends SmsNotifier {
notify(user, text) {
for (let attempt = 1; ; attempt++) {
try {
return super.notify(user, text);
} catch (err) {
if (attempt === 3) throw err;
}
}
}
}
new RetryingSmsNotifier().notify({ phone: '98xxxxxx01' }, 'Your order is on its way');
console.log(outbox);
// Retrying email needs RetryingEmailNotifier, which repeats the retry loop; quiet hours need another level on top
// of every class. With one subclass per combination, the count multiplies; with parts, it adds up.
console.log('channels optional features subclasses for every combination parts to compose');
for (const [channels, features] of [[2, 1], [3, 2], [3, 3], [4, 4]]) {
console.log(String(channels).padEnd(10) + String(features).padEnd(19) + String(channels * 2 ** features).padEnd(34) + (channels + features));
} Output
[ 'sms to 98xxxxxx01: Your order is on its way' ] channels optional features subclasses for every combination parts to compose 2 1 4 3 3 2 12 5 3 3 24 6 4 4 64 8
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node hierarchy.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The code works, and its problems show up as soon as it grows:
- Combinations multiply. Retrying belongs to
RetryingSmsNotifier, so email that retries needs a second class with the same loop, and quiet hours need a further level on top of each. With one subclass per combination, every optional feature doubles the count: three channels with three features need 24 classes, against six parts to compose. - Subclasses depend on their parent’s insides.
RetryingSmsNotifieroverridesnotifyand relies on the parent’snotifycallingdeliverexactly once. A harmless-looking change toNotifier, such as logging beforedeliver, changes the behaviour of every subclass. - Tests have to go through the hierarchy. Retry can only be tested together with SMS, and there is no place to give the classes a clock or a fake gateway.
Inheritance still fits a real “is a” relationship with a short chain and a shared contract: Error subclasses, a
custom element that must extend HTMLElement, a small family of classes that all keep their parent’s promises.
Composition: an object that holds its parts
The same behaviour, built from parts that the notifier is given:
The same notifications, as a class chain and as composed parts
Text description of the diagram
Two groups, one above the other.
The first group, inheritance, is a chain of four classes, each extended by the next: Notifier, SmsNotifier, RetryingSmsNotifier and QuietHoursRetryingSmsNotifier. Each feature is another level, and email with the same features would need a second chain.
The second group, composition, is one Notifier that holds a list of senders and a log, and calls each sender. The first sender is smsSender wrapped in withRetry and then in withQuietHours, which is given a clock. The second is emailSender wrapped in withRetry. The same two wrappers work for any sender, and the clock can be a fake in tests.
// The same notifications composed from parts: senders with the same two methods, wrappers that add a feature to any
// sender, and a notifier that is given its collaborators instead of creating them.
const outbox = [];
const smsSender = {
name: 'sms',
canReach: (user) => Boolean(user.phone),
send: (user, text) => outbox.push(`sms to ${user.phone}: ${text}`),
};
const emailSender = {
name: 'email',
canReach: (user) => Boolean(user.email),
send: (user, text) => outbox.push(`email to ${user.email}: ${text}`),
};
// A sender that fails twice and then works, like a gateway having a bad minute.
function flakySender(sender, failures) {
let left = failures;
return {
...sender,
send(user, text) {
if (left-- > 0) throw new Error(`${sender.name} gateway timed out`);
return sender.send(user, text);
},
};
}
// Features as wrappers: each one works with any sender.
function withRetry(sender, attempts = 3) {
return {
...sender,
send(user, text) {
for (let attempt = 1; ; attempt++) {
try {
return sender.send(user, text);
} catch (err) {
if (attempt >= attempts) throw err;
}
}
},
};
}
function withQuietHours(sender, clock, { from = 22, to = 7 } = {}) {
const quiet = () => clock.hour() >= from || clock.hour() < to;
return { ...sender, canReach: (user) => !quiet() && sender.canReach(user) };
}
class Notifier {
#senders;
#log;
constructor({ senders, log }) {
this.#senders = senders;
this.#log = log;
}
notify(user, text) {
const used = this.#senders.filter((s) => s.canReach(user));
for (const s of used) s.send(user, text);
this.#log(`${user.name}: ${used.map((s) => s.name).join(', ') || 'nobody yet'}`);
return used.length;
}
}
// A fake clock and a log that collects lines: the tests decide the time and read what happened.
const clock = { now: 14, hour() { return this.now; } };
const lines = [];
const notifier = new Notifier({
senders: [withQuietHours(withRetry(flakySender(smsSender, 2)), clock), withRetry(emailSender)],
log: (line) => lines.push(line),
});
notifier.notify({ name: 'Asha', phone: '98xxxxxx01' }, 'Order shipped');
clock.now = 23;
notifier.notify({ name: 'Ravi', phone: '98xxxxxx02', email: 'ravi@example.com' }, 'Order shipped');
notifier.notify({ name: 'Meera', phone: '98xxxxxx03' }, 'Order shipped');
console.log(outbox);
console.log(lines); Output
[ 'sms to 98xxxxxx01: Order shipped', 'email to ravi@example.com: Order shipped' ] [ 'Asha: sms', 'Ravi: email', 'Meera: nobody yet' ]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node notifier.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
- Senders are plain objects with two methods.
Notifieronly callscanReachandsend, so anything that has them is a sender: an SMS gateway, an email client, an in-app inbox or a fake that records messages. That is polymorphism by duck typing, with no base class and no interface declaration. - Features are wrappers.
withRetryandwithQuietHourstake any sender and return a sender with one behaviour added, so they combine freely, and email got its retries without new code. This is the decorator pattern. - Collaborators are passed in.
Notifierreceives its senders and its log, andwithQuietHoursreceives a clock. Passing dependencies in rather than creating them inside, often called dependency injection, is what let the run use a clock set to 14:00 and then 23:00, and a sender that fails twice, without waiting for the night or touching any shared object. - The parts stay private.
Notifierkeeps them in#privatefields, so the only way to use it isnotify.
The cost is a little more wiring at the place where the objects are created. In exchange, each part is small enough to read in one screen and to test on its own.
Mixins with Object.assign
A mixin is a set of methods that several unrelated classes share. The oldest way to apply one copies its methods onto a class’s prototype:
// Mixins copied with Object.assign: what is copied, what is not, and what is silently overwritten.
const describable = {
describe() {
return `${this.constructor.name} ${this.id}`;
},
get label() {
console.log('(label getter ran, this =', this === describable ? 'the mixin object)' : 'an order)');
return `#${this.id}`;
},
};
class Order {
constructor(id) {
this.id = id;
}
describe() {
return 'the class method';
}
}
Object.assign(Order.prototype, describable);
const order = new Order('A-17');
console.log(order.describe()); // the mixin's method replaced the class's own, without a warning
console.log(order.label); // a value computed once, during the copy, from the mixin object
// The fix for accessors: copy property descriptors instead of values.
class Invoice {
constructor(id) {
this.id = id;
}
}
Object.defineProperties(Invoice.prototype, Object.getOwnPropertyDescriptors(describable));
console.log(new Invoice('B-2').label);
// Class methods are not enumerable, so Object.assign copies none of them from a class prototype.
class Greeter {
greet() {
return 'namaste';
}
}
const target = {};
Object.assign(target, Greeter.prototype);
console.log('copied from a class prototype:', Object.keys(target)); Output
(label getter ran, this = the mixin object) Order A-17 #undefined (label getter ran, this = an order) #B-2 copied from a class prototype: []
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node assign-mixin.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Object.assign reads each own enumerable property of the mixin and assigns it to the target, and that causes three
problems:
- It overwrites without a warning. The mixin’s
describereplaced the method thatOrderdeclared itself, and the class never knew. - Getters run once, during the copy.
labelwas read from the mixin object, with the mixin asthis, and the string"#undefined"was stored on the prototype.Object.defineProperties(target, Object.getOwnPropertyDescriptors(mixin))copies the getter itself, as theInvoiceline shows. - Class methods are not enumerable, so
Object.assign(target, Greeter.prototype)copies nothing at all.
A copied method that uses super keeps the object it was written in as its home, so super.describe() inside it
looks at the mixin object’s prototype, not at the class it was copied to. Methods that need super need the next
kind of mixin.
Subclass factories
A subclass factory is a function that takes a class and returns a subclass of it:
// Subclass-factory mixins: functions that take a class and return a subclass of it, so super calls chain through
// every mixin that was applied.
let tick = 0;
const clock = () => ++tick; // a counter instead of the time, so every run prints the same
const Timestamped = (Base) =>
class Timestamped extends Base {
#createdAt = clock();
get createdAt() {
return this.#createdAt;
}
describe() {
return `${super.describe()} (created at tick ${this.#createdAt})`;
}
};
const Serializable = (Base) =>
class Serializable extends Base {
toJSON() {
return { type: this.constructor.name, ...this };
}
describe() {
return `[${super.describe()}]`;
}
};
class Entity {
constructor(id) {
this.id = id;
}
describe() {
return `${this.constructor.name} ${this.id}`;
}
}
class Order extends Timestamped(Serializable(Entity)) {}
const order = new Order('A-17');
console.log(order.describe());
console.log(JSON.stringify(order));
const chain = [];
for (let p = Object.getPrototypeOf(order); p !== null; p = Object.getPrototypeOf(p)) chain.push(p.constructor.name);
console.log(chain.join(' -> '));
// The mixin is an arrow function, which has no prototype, so instanceof cannot use it.
try {
console.log(order instanceof Timestamped);
} catch (err) {
console.log('order instanceof Timestamped:', err.name);
}
// Every call makes a new class, with new private names: two applications are not the same class.
const A = Timestamped(Entity);
const B = Timestamped(Entity);
console.log(A === B, new A('x') instanceof B); Output
[Order A-17] (created at tick 1)
{"type":"Order","id":"A-17"}
Order -> Timestamped -> Serializable -> Entity -> Object
order instanceof Timestamped: TypeError
false false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node subclass-factory.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Timestamped(Serializable(Entity)) builds a real prototype chain, Order → Timestamped → Serializable → Entity → Object, so everything that works for classes works here:
supercalls chain through every mixin. Eachdescribewraps the one below it, so the order of application decides the result: the outermost mixin is nearest toOrderand runs first.- Mixins can have fields, constructors and private state, such as the
#createdAtfield thatTimestampedadds to each order. - Name the class expression.
class Timestamped extends Basegives the chain readable names in logs and stack traces, where an anonymous class would show no name at all.
And it has traps of its own:
order instanceof Timestampedthrows aTypeError, because the mixin is an arrow function without aprototype. Offer anisTimestamped(value)function, or give the mixin a staticSymbol.hasInstancemethod that checks a brand its classes set.- Every call makes a new class.
Timestamped(Entity)twice gives two different classes with different private names, so objects of one do not count as objects of the other. - Two mixins with the same method name resolve by order: the outer one wins. It should call
super.method()when it means to extend the method rather than replace it.
Two or three mixins on a class are easy to follow. A longer chain is a sign that the class wants to hold parts instead.
Object-oriented design the JavaScript way
The usual object-oriented ideas all apply in JavaScript, but they look different from their Java or C# versions:
- Encapsulation:
#privatefields and methods, closures, and modules that export only their public functions. - Polymorphism: duck typing. Any object with
canReachandsendis a sender, and no interface keyword is needed. - Single responsibility: a sender sends, a wrapper adds one feature, and the notifier decides who is told.
- Open for extension: a new channel is a new sender and a new feature is a new wrapper;
Notifierdoes not change. - Substitutability: a subclass or a fake keeps every promise of what it replaces, which the
SortedArrayof Extending built-ins failed to do. - Small interfaces: collaborators ask for two methods, not for a whole class.
- Dependency inversion: objects receive their collaborators, through constructor options or function parameters, instead of creating them.
These are design principles first, and the LLD track treats each one at the design level, with diagrams and case studies: single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion.
JavaScript & TypeScript Online Compiler Wrap the notifier's email sender in withQuietHours too, and give it a clock that says 23.Exercise: from a class hierarchy to composed rules
Exercise · Medium · JavaScript
Turn a fare class hierarchy into composed rules
A cab app prices rides with three classes, each extending the last: Fare charges 25 rupees plus a rate for every started kilometre, NightFare adds 25 % (rounded to the nearest rupee) from 22:00 to 05:59, and AirportNightFare adds 100 for an airport trip. The classes read the hour from a shared systemClock, so a test can only change the time by replacing systemClock.hour for everyone.
Keep the three classes and systemClock as they are, and add the same behaviour built from parts:
nightRule(amount, context)returnsamountraised by 25 % and rounded whencontext.houris 22, 23 or 0 to 5, andamountunchanged otherwise.airportRule(amount, context)returnsamount + 100.createFare({ ratePerKm = 12, rules = [], clock = systemClock } = {})returns an object withprice(km). It computes25 + Math.ceil(km) * ratePerKm, then passes the amount through each rule in order, asamount = rule(amount, { hour: clock.hour(), km }), and returns the result.
createFare({ rules: [nightRule, airportRule] }) must price every ride exactly as new AirportNightFare() does, and a rule is any function with that shape, so new rules need no new class:
const nightAtAirport = createFare({ rules: [nightRule, airportRule], clock: { hour: () => 23 } });
nightAtAirport.price(3); // 25 + 3 × 12 = 61, then 76 at night, then 176
createFare().price(0.5); // 37: a started kilometre counts as a whole one
The starter's createFare and rules are not written yet: run the sample tests to see what they expect.
Starter code · fares.mjs
/** The hour of day, 0 to 23, shared by every fare class below. */
export const systemClock = { hour: () => new Date().getHours() };
// The existing code: three classes, each extending the last. Keep them as they are.
export class Fare {
constructor(ratePerKm = 12) {
this.ratePerKm = ratePerKm;
}
price(km) {
return 25 + Math.ceil(km) * this.ratePerKm;
}
}
export class NightFare extends Fare {
price(km) {
const amount = super.price(km);
const hour = systemClock.hour();
return hour >= 22 || hour < 6 ? Math.round(amount * 1.25) : amount;
}
}
export class AirportNightFare extends NightFare {
price(km) {
return super.price(km) + 100;
}
}
// Write these three: the same fares, built from rules and a clock that are passed in.
export function nightRule(amount, context) {
throw new Error('nightRule is not written yet');
}
export function airportRule(amount, context) {
throw new Error('airportRule is not written yet');
}
export function createFare(options = {}) {
throw new Error('createFare is not written yet');
} The sample tests · fares.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { AirportNightFare, Fare, NightFare, airportRule, createFare, nightRule, systemClock } from './fares.mjs';
const at = (hour) => ({ hour: () => hour });
test('the distance price, with every started kilometre charged', () => {
const fare = createFare();
assert.equal(fare.price(0.5), 37);
assert.equal(fare.price(3), 61);
assert.equal(fare.price(3.2), 73);
assert.equal(createFare({ ratePerKm: 15 }).price(2), 55);
});
test('nightRule raises fares from 22:00 to 05:59 only', () => {
for (const hour of [22, 23, 0, 3, 5]) assert.equal(nightRule(61, { hour, km: 3 }), 76, `hour ${hour}`);
for (const hour of [6, 12, 21]) assert.equal(nightRule(61, { hour, km: 3 }), 61, `hour ${hour}`);
assert.equal(nightRule(37, { hour: 23, km: 1 }), 46);
assert.equal(airportRule(61, { hour: 12, km: 3 }), 161);
});
test('the injected clock decides the hour', () => {
const night = createFare({ rules: [nightRule], clock: at(23) });
const day = createFare({ rules: [nightRule], clock: at(9) });
assert.equal(night.price(3), 76);
assert.equal(day.price(3), 61);
});
test('rules apply in the order given', () => {
assert.equal(createFare({ rules: [nightRule, airportRule], clock: at(23) }).price(3), 176);
assert.equal(createFare({ rules: [airportRule, nightRule], clock: at(23) }).price(3), 201);
assert.equal(createFare({ rules: [nightRule, airportRule], clock: at(14) }).price(3), 161);
});
test('any function with the same shape is a rule', () => {
const festivalDiscount = (amount) => amount - 10;
const longTrip = (amount, { km }) => (km > 20 ? amount + 50 : amount);
assert.equal(createFare({ rules: [festivalDiscount], clock: at(12) }).price(3), 51);
assert.equal(createFare({ rules: [longTrip], clock: at(12) }).price(25), 375);
assert.equal(createFare({ rules: [longTrip], clock: at(12) }).price(5), 85);
});
test('prices every ride exactly as the three classes do', () => {
const realHour = systemClock.hour;
try {
for (const hour of [0, 5, 6, 12, 21, 22, 23]) {
systemClock.hour = () => hour;
for (const km of [0.4, 1, 2.5, 7, 19.9]) {
assert.equal(createFare().price(km), new Fare().price(km), `Fare at ${hour}:00 for ${km} km`);
assert.equal(createFare({ rules: [nightRule] }).price(km), new NightFare().price(km), `NightFare at ${hour}:00 for ${km} km`);
assert.equal(createFare({ rules: [nightRule, airportRule] }).price(km), new AirportNightFare().price(km), `AirportNightFare at ${hour}:00 for ${km} km`);
}
}
} finally {
systemClock.hour = realHour;
}
});
test('fares do not share their parts', () => {
const a = createFare({ ratePerKm: 10, rules: [nightRule], clock: at(23) });
const b = createFare({ ratePerKm: 20, clock: at(23) });
assert.equal(a.price(1), 44);
assert.equal(b.price(1), 45);
}); A hint
Write the two rules first: they are plain functions that take an amount and a context and return an amount, so they need no class. In createFare, keep the options in variables, and in price(km) read the hour once, build the context object, and apply the rules with rules.reduce((amount, rule) => rule(amount, context), start), where start is the distance price.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Self-check
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between composition and inheritance, and when would you choose each?
Inheritance makes one class a special case of another: class Bus extends Vehicle gets every method of Vehicle
through the prototype chain and can override them. Composition gives an object other objects to hold and call, such
as a notifier that is given its senders and a clock. Inheritance suits a real “is a” relationship with a short chain
whose subclasses keep every promise of the parent, such as Error subclasses. Composition suits behaviour that varies
or combines: features become small parts that can be swapped, reused across classes and replaced by fakes in tests,
whereas one subclass per combination of features multiplies quickly. In JavaScript, composition is also natural
because any object with the right methods can stand in for another, without a shared base class.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A codebase has Notifier, SmsNotifier, RetryingSmsNotifier and QuietHoursRetryingSmsNotifier, and product now wants email and WhatsApp with the same retry and quiet-hours options. Propose a design that does not add a class per combination, show how you would migrate the existing callers safely, and explain how you would test it.
-
Mid to senior
Two teams publish mixins for your Order class, and both define save(): Auditable writes an audit entry and Cached clears a cache. With Object.assign only one save runs. With subclass factories, the order is sometimes not saved at all, because one team's save never calls super.save(). Explain both behaviours and design mixins that combine safely.
Key takeaways
- Inheritance shares behaviour by being; composition by having. Deep hierarchies multiply classes, tie subclasses to their parent’s internals and are hard to test.
- A composed object holds small collaborators and calls them; wrappers add one feature to any collaborator, and passing collaborators in lets tests use fakes such as a fixed clock.
- Duck typing is JavaScript’s polymorphism: any object with the methods a caller uses will do.
Object.assignmixins overwrite silently, freeze getters into values and copy nothing from class prototypes; copy descriptors if you use them.- Subclass-factory mixins build a real chain with working
super, fields and private state; their order matters, they cannot be used withinstanceofdirectly, and each application creates a new class. - Encapsulation, single responsibility, open/closed, substitutability and dependency inversion all apply, expressed with private fields, small objects, wrappers and injected collaborators.
References
- Inheritance and the prototype chain (MDN Web Docs)
- ECMAScript 2026 Language Specification: Object.assign (Ecma International)
- ECMAScript 2026 Language Specification: Class Definitions (Ecma International)
- ECMAScript 2026 Language Specification: InstanceofOperator (Ecma International)
- extends (MDN Web Docs)
- Object.getOwnPropertyDescriptors() (MDN Web Docs)
- HTML Standard: Custom elements (WHATWG)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress