Inheritance vs. Composition
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
Two classes can share behavior without becoming a family. That is the design choice at the end of object-oriented JavaScript: use inheritance when one thing truly is a specialized version of another, and use composition when you want objects to collaborate without a brittle family tree.
Inherit for honest "is-a" relationships
Inheritance means one class creates instances whose prototype chain includes another class's prototype. A base class is the more general class, and a subclass is the more specific class that extends it. A true is-a relationship means every subclass instance honestly counts as a kind of the base class.
class PracticeRound {
constructor(playerName, score) {
this.playerName = playerName;
this.score = score;
}
addPoints(points) {
this.score = this.score + points;
return this.score;
}
label() {
return `${this.playerName}: ${this.score} points`;
}
}
class TimedPracticeRound extends PracticeRound {
constructor(playerName, score, secondsLeft) {
super(playerName, score);
this.secondsLeft = secondsLeft;
}
label() {
return `${super.label()} with ${this.secondsLeft}s left`;
}
}
const round = new TimedPracticeRound("Mina", 18, 45);
round.addPoints(4);
console.log(round.label());
console.log(round instanceof TimedPracticeRound);
console.log(round instanceof PracticeRound);→ Mina: 22 points with 45s left, true, then true.
The keyword extends links the subclass prototype to the base class prototype. The call super(...) runs the base constructor so PracticeRound can set up playerName and score before TimedPracticeRound adds secondsLeft.
Think of this as adding a new labeled card in front of the backup card from 11.1. Lookup checks the timed round first, then TimedPracticeRound.prototype, then PracticeRound.prototype; super.label() is what deliberately reaches the base method.
Override only when the meaning changes
Method overriding means a subclass defines a method with the same name as a base-class method, so lookup finds the subclass version first. Inside an overridden method, super.methodName(...) calls the base version.
class MemberBadge {
constructor(playerName, score) {
this.playerName = playerName;
this.score = score;
}
status() {
return `${this.playerName}: ${this.score}`;
}
}
class ProMemberBadge extends MemberBadge {
constructor(playerName, score, plan) {
super(playerName, score);
this.plan = plan;
}
status() {
return `${super.status()} (${this.plan})`;
}
}
const basic = new MemberBadge("Rae", 12);
const pro = new ProMemberBadge("Ira", 21, "pro");
console.log(basic.status());
console.log(pro.status());
console.log(Object.hasOwn(pro, "status"));
console.log(Object.hasOwn(ProMemberBadge.prototype, "status"));→ Rae: 12, Ira: 21 (pro), false, then true.
pro.status() finds the override on ProMemberBadge.prototype. That override still reuses the base behavior with super.status(), then adds the plan.
Use overriding when the subclass keeps the same promise but needs a more specific version. A pro badge is still a member badge. A receipt is not a member badge just because both can print a label.
Polymorphism is about shared behavior
Polymorphism means the same operation works with different kinds of objects. In a dynamic language, which means JavaScript checks behavior while the program runs, the objects do not need to share a declared parent class. They just need the method you call.
class ScoreCard {
constructor(playerName, score) {
this.playerName = playerName;
this.score = score;
}
report() {
return `${this.playerName}: ${this.score} points`;
}
}
class MoodCard {
constructor(playerName, mood) {
this.playerName = playerName;
this.mood = mood;
}
report() {
return `${this.playerName}: ${this.mood}`;
}
}
const snackReceipt = {
report() {
return "Snack total: $6";
},
};
function printReport(item) {
console.log(item.report());
}
printReport(new ScoreCard("Mina", 30));
printReport(new MoodCard("Rae", "focused"));
printReport(snackReceipt);→ Mina: 30 points, Rae: focused, then Snack total: $6.
printReport does not ask whether item instanceof ScoreCard. It asks for item.report(). That is often the JavaScript way: name the behavior you need, then pass any object that can do it.
Compose capabilities instead
Composition means building an object by giving it other objects or functions to use. Instead of asking "what parent should this class extend?", ask "what helpers should this object have?"
const streakBonus = {
pointsFor(basePoints, streak) {
return basePoints + streak;
},
};
const plainBonus = {
pointsFor(basePoints) {
return basePoints;
},
};
const quietReporter = {
report(playerName, score) {
return `${playerName}: ${score} points`;
},
};
class ScoreTracker {
constructor(playerName, bonusPolicy, reporter) {
this.playerName = playerName;
this.score = 0;
this.bonusPolicy = bonusPolicy;
this.reporter = reporter;
}
addRound(basePoints, streak) {
const earned = this.bonusPolicy.pointsFor(basePoints, streak);
this.score = this.score + earned;
return this.reporter.report(this.playerName, this.score);
}
}
const practice = new ScoreTracker("Bo", streakBonus, quietReporter);
const warmup = new ScoreTracker("Nia", plainBonus, quietReporter);
console.log(practice.addRound(10, 3));
console.log(warmup.addRound(10, 3));→ Bo: 13 points, then Nia: 10 points.
No subclass is needed. ScoreTracker has a bonus policy and a reporter. You can swap either helper without inventing StreakQuietScoreTracker, PlainQuietScoreTracker, and every other combination.
Try it — swap the helper
Run this, then change doubleBonus to steadyBonus in the finals tracker. The class stays the same; the behavior changes because the helper object changes.
Composition feels less dramatic than inheritance. That is the point. Small helper objects are easy to test, easy to replace, and easy to combine.
Mixins are composition with class syntax
A mixin is reusable behavior that gets added to a class, usually by a function that takes a base class and returns a subclass. Mixins are a brief tool, not a default style.
function withPracticeNotes(BaseClass) {
return class extends BaseClass {
notes = [];
constructor(playerName) {
super(playerName);
}
addNote(note) {
this.notes.push(note);
return `${this.playerName}: ${this.notes.length} note(s)`;
}
};
}
class PlayerCard {
constructor(playerName) {
this.playerName = playerName;
}
label() {
return `${this.playerName} card`;
}
}
const NotedPlayerCard = withPracticeNotes(PlayerCard);
const card = new NotedPlayerCard("Mina");
console.log(card.label());
console.log(card.addNote("review loops"));
console.log(card instanceof PlayerCard);→ Mina card, Mina: 1 note(s), then true.
Mixins can be useful when several classes need the same small capability. They can also hide where methods came from if you stack too many. Reach for them after plain composition feels too awkward, not before.
You now have the balanced object-oriented toolbox: prototypes under the hood, classes for clear object families, privacy for internal state, inheritance for true "is-a" relationships, and composition for flexible collaboration. Next, the course turns to async JavaScript: objects still show up, but the new challenge is coordinating work over time.
Checkpoint
Answer all three to mark this lesson complete