Loading content...
The IS-A test sounds almost trivially simple: "Does B is-a A make sense?" Yet this simple question has caused more heated design debates, more refactoring nightmares, and more inheritance disasters than perhaps any other concept in object-oriented design.
Why is something so simple so hard?
Because natural language is imprecise, our intuitions are often wrong, and the shortcuts we take when speaking don't apply to code. When we casually say a penguin "is a" bird, we're glossing over important behavioral differences that become critical in software. When we say a square "is a" rectangle, we're ignoring the dimensional constraints that break substitutability.
This page will give you a rigorous, disciplined approach to the IS-A test—one that catches the subtle traps that casual thinking misses.
By the end of this page, you will have a systematic, multi-step process for applying the IS-A test. You'll understand the difference between linguistic truth and behavioral truth, and you'll be equipped with specific questions that reveal hidden inheritance problems before they manifest in production code.
The biggest mistake developers make when applying the IS-A test is relying on linguistic truth—whether the statement sounds correct in natural language—rather than behavioral truth—whether the relationship holds in terms of object behavior.
Linguistic truth is necessary but not sufficient.
If "B is-a A" sounds wrong linguistically, it's definitely wrong for inheritance. But just because it sounds right doesn't mean the relationship is valid. Consider these examples:
| Statement | Linguistically | Behaviorally | Why |
|---|---|---|---|
| A Square is-a Rectangle | ✅ True | ❌ Often False | Squares can't have width ≠ height; violates rectangle contracts |
| A Penguin is-a Bird | ✅ True | ❌ False (if Birds fly) | Penguins can't fly; violates Bird.fly() contract |
| A Circle is-a Ellipse | ✅ True | ❌ Risky | Circles can't have different axes; may violate ellipse mutation |
| An ArrayList is-a List | ✅ True | ✅ True | ArrayList honors all List contracts completely |
| A Dog is-an Animal | ✅ True | ✅ True | Dog extends Animal behavior without restriction |
The key insight:
In object-oriented programming, IS-A means "can be substituted for." The question isn't "is this word a kind of that word" but rather "can objects of this class replace objects of that class in all situations without breaking anything?"
This subtle but critical distinction transforms the IS-A test from a casual vocabulary check into a rigorous design analysis.
Natural language evolved to help humans communicate, not to model software behavior. Biological taxonomy doesn't translate directly to class hierarchies. A penguin is biologically a bird, but if your Bird class has fly() as a required behavior, Penguin cannot extend it. Always prioritize behavioral truth over linguistic intuition.
Here is a systematic, step-by-step protocol for applying the IS-A test rigorously. Work through each step in order; if any step fails, inheritance is inappropriate.
Don't skip steps or assume they pass. Write down the answer to each step for important inheritance decisions. This documentation helps during code reviews and serves as a design decision record for future maintainers.
Let's apply the IS-A test protocol systematically to classic examples that have challenged generations of software engineers.
The Question: Should Square extend Rectangle?
Linguistic Check: ✅ Pass — "A Square is-a Rectangle" is mathematically true.
Universal Applicability: ⚠️ Warning — This is where problems begin.
Method Compatibility Analysis:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
class Rectangle { protected width: number; protected height: number; constructor(width: number, height: number) { this.width = width; this.height = height; } setWidth(width: number): void { this.width = width; } setHeight(height: number): void { this.height = height; } getArea(): number { return this.width * this.height; }} class Square extends Rectangle { constructor(side: number) { super(side, side); } // 💥 PROBLEM: We must override these to maintain invariant setWidth(width: number): void { this.width = width; this.height = width; // Force both to match } setHeight(height: number): void { this.height = height; this.width = height; // Force both to match }} // Client code that uses Rectangle (unaware of Square)function testRectangle(rect: Rectangle): void { rect.setWidth(5); rect.setHeight(4); // Rectangle contract: area should be width × height = 20 console.log(rect.getArea()); // For Rectangle: 20 ✅ // For Square: 16 ❌ (because setHeight changed width too)}Verdict: ❌ Inheritance inappropriate. The Square's invariant (width === height) conflicts with Rectangle's implicit contract that setWidth() and setHeight() are independent operations.
Solution: Make Rectangle and Square siblings, both extending abstract Shape. Or, make both immutable (no setters), which eliminates the behavioral conflict.
A critical principle for the IS-A test is what we call the 100% Rule:
If the IS-A relationship doesn't hold in 100% of cases, it doesn't hold at all.
This seems strict, and it is. But it reflects the reality of how code works. When you write code against a parent class, you expect it to work for ALL subclasses. A single exception breaks the abstraction.
Why 100%?
Because code doesn't come with asterisks. You can't write:
function processShapes(shapes: Shape[]): void {
for (const shape of shapes) {
shape.draw(); // Works for all shapes*
// * Except InvisibleShape, which throws an error
}
}
The exception handling, the special cases, the defensive checks—they all pollute your codebase and negate the very abstraction you sought from inheritance.
If you ever find yourself saying "most" or "usually" when describing an inheritance relationship, that's a signal to reconsider. "Most X can do Y" is not the same as "All X can do Y." Design for the latter.
A subtle but important insight is that IS-A relationships are context-dependent. Whether "B is-a A" holds depends not on abstract mathematical truth but on how A is defined in your specific system.
The same objects can have different valid hierarchies in different contexts:
| Context / System | Penguin ← Bird? | Reasoning |
|---|---|---|
| Ornithology Database | ✅ Yes | Bird just stores data; no flight behavior |
| Flight Simulator | ❌ No | Bird.fly() is essential; Penguin can't comply |
| Zoo Management | ✅ Yes | Birds have habitats and diets; flight irrelevant |
| Bird Migration Tracker | ❌ No | All tracked birds must migrate by flying |
| Bird Anatomy Guide | ✅ Yes | Focuses on physical structure, not behavior |
The profound implication:
You aren't modeling "the real world" when designing class hierarchies. You're modeling your application's needs. A hierarchy that's perfect for one application may be completely wrong for another.
This is why you should always:
Don't try to create "the correct" object model of reality. Create the correct object model for your application's needs. Ask "What does my system need these objects to do?" not "What are these objects in the real world?"
The most powerful tool for testing IS-A relationships is the Substitution Thought Experiment. Before writing any code, mentally walk through scenarios:
The Exercise:
Imagine a function written by someone who only knows about the parent class. They've never heard of your child class. Now, imagine passing your child object to that function.
If the answer to any of these is "no" or "maybe," your IS-A relationship is questionable.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// The author of this function only knows about Vehicle// They expect ALL vehicles to work with this code interface Vehicle { start(): void; accelerate(mph: number): void; brake(): void; getCurrentSpeed(): number;} function roadTest(vehicle: Vehicle): TestResult { vehicle.start(); vehicle.accelerate(30); // Expect: vehicle is moving at ~30 mph if (vehicle.getCurrentSpeed() < 25) { return { passed: false, reason: "Acceleration too slow" }; } vehicle.brake(); // Expect: vehicle has stopped if (vehicle.getCurrentSpeed() > 0) { return { passed: false, reason: "Brakes ineffective" }; } return { passed: true, reason: "All checks passed" };} // Now test various candidates: class Car implements Vehicle { /* ... */ }// ✅ Passes thought experiment - cars behave as expected class Bicycle implements Vehicle { // ⚠️ accelerate(30) - can a bicycle reach 30 mph? // ⚠️ brake() - bicycle braking behaves differently // Maybe passes, but edge cases exist} class Boat implements Vehicle { // ❌ Road test doesn't make sense for boats // The "accelerate on road" assumption is violated // Fails the thought experiment} class HorselessCarriage implements Vehicle { // ✅ Despite different mechanics, behaves like a vehicle // Passes the thought experiment}Key insight from the thought experiment:
The test reveals assumptions embedded in how parent classes are used. Code written against Vehicle assumes road-based operation. A Boat may technically implement all methods, but it doesn't fit the semantic expectations.
When running the thought experiment, look for:
Synthesizing all the concepts from this page, here's a formalized decision process for IS-A validation:
123456789101112131415161718192021222324252627282930313233343536373839404142
function shouldInherit(Child, Parent) -> boolean: // Phase 1: Linguistic Screening if NOT linguisticallySound("Child IS-A Parent"): return false // Phase 2: Universal Applicability if EXISTS case where "Child IS-A Parent" is false: return false // Phase 3: Method-by-Method Analysis for each method M in Parent.publicMethods: if Child cannot implement M meaningfully: return false if Child.M would throw "not supported": return false if Child.M has different semantics than Parent.M: return false // Phase 4: Contract Verification for each method M in Parent.publicMethods: if Child.M.preconditions are STRONGER than Parent.M: return false if Child.M.postconditions are WEAKER than Parent.M: return false for each invariant I in Parent.invariants: if Child cannot maintain I: return false // Phase 5: Substitution Scenarios for each common usage pattern of Parent: mentally simulate using Child instead if behavior would be incorrect or surprising: return false // Phase 6: Future Evolution if likely future Parent changes would break Child: return false with warning // All checks passed return trueYou now have a rigorous, systematic approach to applying the IS-A test. Remember: linguistic truth is not enough—you need behavioral truth. Use the substitution thought experiment, check the 100% rule, and always consider context. In the next page, we'll examine common inheritance mistakes that developers make even when they think they're applying IS-A correctly.