Inheritance as a Useful Abstraction
Inheritance as a Useful Abstraction
“Favor composition over inheritance” is good advice — but it’s often misread as “never use inheritance.” In practice, inheritance is a powerful tool when applied to the right problem. Let me show you a concrete case where it shines.
The problem: branching everywhere
Imagine you’re building a drawing app. You have shapes — circles, rectangles, triangles — and you need to draw them and compute their areas. A natural first pass looks like this:
function draw(ctx, shape) {
if (shape.type === 'circle') {
ctx.beginPath();
ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
ctx.fill();
} else if (shape.type === 'rectangle') {
ctx.fillRect(shape.x, shape.y, shape.width, shape.height);
} else if (shape.type === 'triangle') {
ctx.beginPath();
ctx.moveTo(shape.x, shape.y - shape.size * 0.5);
ctx.lineTo(shape.x - shape.size * 0.5, shape.y + shape.size * 0.5);
ctx.lineTo(shape.x + shape.size * 0.5, shape.y + shape.size * 0.5);
ctx.closePath();
ctx.fill();
}
}
function area(shape) {
if (shape.type === 'circle') {
return Math.PI * shape.radius ** 2;
} else if (shape.type === 'rectangle') {
return shape.width * shape.height;
} else if (shape.type === 'triangle') {
return 0.5 * shape.base * shape.height;
}
} This works. But notice the pattern: every function that touches shapes needs the same if/else chain. Drawing, area calculation, bounding box detection, serialization — they all branch on shape.type.
Now add a pentagon. You have to find every one of those branches and update it. Miss one, and you get a silent bug at runtime.
This is the Open/Closed Principle violation — the code isn’t closed for modification when you extend it with new types.
The inheritance model
Here’s the same problem modeled with a class hierarchy:
The abstract Shape base class defines the interface — draw() and area() — while each subclass provides its own implementation. The key insight: calling code doesn’t need to know which shape it’s dealing with.
class Shape {
constructor(x, y, color) {
this.x = x;
this.y = y;
this.color = color;
}
draw(ctx) {
throw new Error('Subclass must implement draw()');
}
area() {
throw new Error('Subclass must implement area()');
}
} Each shape owns its behavior:
class Circle extends Shape {
constructor(x, y, color, radius) {
super(x, y, color);
this.radius = radius;
}
draw(ctx) {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
}
area() {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(x, y, color, width, height) {
super(x, y, color);
this.width = width;
this.height = height;
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.width, this.height);
}
area() {
return this.width * this.height;
}
} Now the rendering loop is dead simple:
for (const shape of shapes) {
shape.draw(ctx);
} No branching. No type checks. Adding a Pentagon class means writing one new file — zero changes to existing code.
See it in action
Toggle between the procedural and inheritance-based approaches below. Add or remove shapes and watch them animate — the rendering code is the same either way, but the structure behind it tells a very different story.
function draw(shape) {
if (shape.type === 'circle') {
drawCircle(shape.x, shape.y, shape.radius);
} else if (shape.type === 'rectangle') {
drawRect(shape.x, shape.y, shape.w, shape.h);
} else if (shape.type === 'triangle') {
drawTriangle(shape.x, shape.y, shape.base);
}
// add pentagon? another else-if...
}
function area(shape) {
if (shape.type === 'circle') {
return Math.PI * shape.radius ** 2;
} else if (shape.type === 'rectangle') {
return shape.w * shape.h;
} else if (shape.type === 'triangle') {
return 0.5 * shape.base * shape.height;
}
// same branching, repeated...
}Notice how in the procedural tab, every new shape type means touching two functions (and in a real app, likely many more). In the inheritance tab, new types are additive — you write a new class and the existing system just works.
Why this matters
The benefit isn’t about saving lines of code. It’s about where knowledge lives.
In the procedural version, knowledge about how to draw a circle is scattered across every function that handles shapes. In the OOP version, Circle knows everything about being a circle — drawing, area, bounding box, serialization — all in one place.
This has real consequences:
- Adding types is safe. You can’t forget to handle the new type somewhere because the base class forces you to implement the required methods.
- Removing types is safe. Delete the class, and the compiler (or runtime) tells you exactly what breaks.
- Testing is focused. Each shape is tested in isolation. No combinatorial explosion of type × function.
- Reading code is easier. Want to understand how triangles are drawn? Open
Triangle.js. Done.
When NOT to use inheritance
Inheritance isn’t always the answer. It works best when:
- You have a clear “is-a” relationship (a Circle is a Shape)
- Subtypes share a common interface but differ in implementation
- You’re frequently adding new types that fit the existing interface
It works poorly when:
- The hierarchy is deep (more than 2-3 levels gets painful)
- You need to mix behaviors from multiple parents (the “diamond problem”)
- The variation is in what operations you perform, not what types you have
For that last case — where you keep adding new operations rather than new types — patterns like the Visitor or simply using composition tend to work better. The classic “expression problem” in language design is exactly this tension.
The takeaway
Inheritance is a tool, not a religion. When you find yourself writing the same switch or if/else chain across multiple functions, all branching on some .type field — that’s a signal. Inheritance lets you replace those scattered branches with polymorphism: each type carries its own behavior, and calling code stays blissfully generic.
Don’t reach for it by default. But when the shoe fits, it’s one of the cleanest abstractions we have.