Loading learning content...
The history of object-oriented programming is littered with inheritance disasters—class hierarchies that seemed reasonable at the time but evolved into unmaintainable nightmares. The good news? These mistakes tend to follow predictable patterns. By studying them, you can develop the instincts to recognize and avoid them before they take root in your codebase.
Every experienced engineer has made these mistakes. The difference between good and great engineers is often just that great engineers learned from their mistakes (and the mistakes of others) faster.
This page catalogs the most common and damaging inheritance anti-patterns. For each, we'll examine why developers fall into the trap, what goes wrong, and how to restructure the design correctly.
By the end of this page, you will recognize the warning signs of inappropriate inheritance before you write the code. You'll understand the root causes of common mistakes and have concrete alternative designs ready to apply when you encounter these situations.
The most common inheritance mistake is using it purely as a code-sharing mechanism, ignoring whether an IS-A relationship actually exists.
The thought process:
"Class B needs some methods that Class A already has. If B extends A, I get those methods for free!"
Why it's tempting:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// ❌ BAD: Inheritance for code reuse without IS-A class DatabaseConnection { protected connectionString: string; protected isConnected: boolean = false; connect(): void { console.log(`Connecting to ${this.connectionString}`); this.isConnected = true; } disconnect(): void { this.isConnected = false; } logActivity(message: string): void { console.log(`[DB] ${new Date().toISOString()}: ${message}`); }} // ❌ FileHandler extends DatabaseConnection just to get logging!class FileHandler extends DatabaseConnection { private filePath: string; constructor(filePath: string) { super(); this.filePath = filePath; } readFile(): string { this.logActivity(`Reading file: ${this.filePath}`); // Read file logic... return "file contents"; } // 💥 Problem: FileHandler inherits connect(), disconnect() // These methods make NO SENSE for a file handler! // What does it mean to "disconnect" a file handler?} // Client code is now confusedfunction doWork(db: DatabaseConnection): void { db.connect(); // ... work ... db.disconnect();} const fileHandler = new FileHandler("/path/to/file");doWork(fileHandler); // Compiles! But semantically nonsensical.What goes wrong:
FileHandler exposes connect() and disconnect() methods that make no senseFileHandler can be passed to functions expecting DatabaseConnection, causing confusionDatabaseConnection may break FileHandler unexpectedlyThe fix: Use composition instead.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// ✅ GOOD: Composition for code reuse class Logger { log(message: string): void { console.log(`[${new Date().toISOString()}]: ${message}`); }} class DatabaseConnection { private logger: Logger; private connectionString: string; private isConnected: boolean = false; constructor(connectionString: string, logger: Logger) { this.connectionString = connectionString; this.logger = logger; } connect(): void { this.logger.log(`Connecting to ${this.connectionString}`); this.isConnected = true; } disconnect(): void { this.isConnected = false; }} class FileHandler { private logger: Logger; private filePath: string; constructor(filePath: string, logger: Logger) { this.filePath = filePath; this.logger = logger; } readFile(): string { this.logger.log(`Reading file: ${this.filePath}`); return "file contents"; } // No confusing connect()/disconnect() methods! // Clean, focused API.} // Both use Logger, but neither inherits from the otherconst logger = new Logger();const db = new DatabaseConnection("mysql://...", logger);const file = new FileHandler("/path/to/file", logger);If you're considering inheritance primarily to reuse a few methods, stop. Ask yourself: "Does the IS-A relationship genuinely hold?" If you can't confidently say yes, use composition. Extract the shared code into a separate class and inject it where needed.
The Square-Rectangle problem is so fundamental to understanding inheritance that it has its own name. The mistake occurs when a mathematically true IS-A relationship ("a square is a rectangle") becomes behaviorally false due to mutability.
The core issue: Mathematics defines rectangles by their properties at a single moment. Object-oriented design defines objects by their behavior over time, including how they change.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// ❌ The Problem 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); } // Must override to maintain square invariant setWidth(width: number): void { this.width = width; this.height = width; } setHeight(height: number): void { this.width = height; this.height = height; }} // The problem manifests here:function doubleWidth(rect: Rectangle): void { const originalArea = rect.getArea(); const originalWidth = originalArea / rect.getArea(); // Simplified rect.setWidth(rect.getWidth() * 2); // Expected: area doubled (if only width changes) // Actual for Square: area quadrupled (both dimensions changed)!} const square = new Square(5);console.log(square.getArea()); // 25 doubleWidth(square);// Expected: width=10, height=5, area=50// Actual: width=10, height=10, area=100 💥Why this is insidious:
Solutions:
Whenever a subclass has a stricter invariant than its parent, and the parent allows mutations that would violate that invariant, you have a Square-Rectangle trap. Watch for phrases like "X is a Y, but with an additional constraint on..."
A child class that throws "not supported" exceptions or makes inherited methods do nothing is almost always a design error. This pattern has a name: the "Refused Bequest" code smell.
Why it happens:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// ❌ BAD: Restricting inherited behavior class Stack<T> { protected items: T[] = []; push(item: T): void { this.items.push(item); } pop(): T | undefined { return this.items.pop(); } peek(): T | undefined { return this.items[this.items.length - 1]; } // Additional utility methods insertAt(index: number, item: T): void { this.items.splice(index, 0, item); } removeAt(index: number): T | undefined { return this.items.splice(index, 1)[0]; }} // ❌ "Queue" that inherits Stack and refuses some behaviorsclass Queue<T> extends Stack<T> { // Override push to be enqueue (okay so far) enqueue(item: T): void { this.items.unshift(item); } // Use pop() as dequeue (okay) dequeue(): T | undefined { return this.pop(); } // ❌ PROBLEM: Refuse insertAt because queues don't allow it insertAt(index: number, item: T): void { throw new Error("Queues don't support arbitrary insertion"); } // ❌ PROBLEM: Refuse removeAt removeAt(index: number): T | undefined { throw new Error("Queues don't support arbitrary removal"); } // Also: what about push() and peek()? Are they valid for Queue?} // Code expecting a Stack will break with Queuefunction reorderStack<T>(stack: Stack<T>): void { const temp = stack.removeAt(0); // 💥 Throws for Queue! stack.insertAt(2, temp!); // 💥 Throws for Queue!}Why this is wrong:
The fix: Recognize that Stack and Queue are peers, not parent-child. Both implement a more general abstraction.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// ✅ GOOD: Correct hierarchy interface Collection<T> { add(item: T): void; remove(): T | undefined; peek(): T | undefined; isEmpty(): boolean;} class Stack<T> implements Collection<T> { private items: T[] = []; add(item: T): void { this.items.push(item); } remove(): T | undefined { return this.items.pop(); } peek(): T | undefined { return this.items[this.items.length - 1]; } isEmpty(): boolean { return this.items.length === 0; } // Stack-specific methods (not in interface) pushMultiple(items: T[]): void { this.items.push(...items); }} class Queue<T> implements Collection<T> { private items: T[] = []; add(item: T): void { this.items.push(item); } remove(): T | undefined { return this.items.shift(); } peek(): T | undefined { return this.items[0]; } isEmpty(): boolean { return this.items.length === 0; } // Queue-specific methods (not in interface) priorityAdd(item: T, priority: number): void { this.items.splice(priority, 0, item); }} // Now code written against Collection works with bothfunction processCollection<T>(collection: Collection<T>): void { while (!collection.isEmpty()) { console.log(collection.remove()); } // No exceptions! Both Stack and Queue work correctly.}A God Parent Class is a base class that tries to provide everything any child might ever need. It accumulates functionality over time until it becomes a bloated, coupled, unmaintainable monster.
How it happens:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// ❌ BAD: God Parent Class abstract class BaseController { // Authentication - needed by some controllers protected currentUser: User | null = null; protected isAuthenticated(): boolean { /* ... */ } protected requireAuth(): void { /* ... */ } protected hasPermission(perm: string): boolean { /* ... */ } // Logging - added for convenience protected logger: Logger; protected logInfo(msg: string): void { /* ... */ } protected logError(msg: string, err: Error): void { /* ... */ } protected logDebug(msg: string): void { /* ... */ } // Caching - needed by some performance-critical controllers protected cache: Cache; protected getCached<T>(key: string): T | null { /* ... */ } protected setCached<T>(key: string, value: T): void { /* ... */ } protected invalidateCache(key: string): void { /* ... */ } // Validation - used by form controllers protected validateRequired(val: any): boolean { /* ... */ } protected validateEmail(val: string): boolean { /* ... */ } protected validatePhone(val: string): boolean { /* ... */ } protected validationErrors: string[] = []; // Response formatting - for API controllers protected jsonResponse(data: any): Response { /* ... */ } protected errorResponse(msg: string): Response { /* ... */ } protected htmlResponse(template: string): Response { /* ... */ } // Transaction management - for DB controllers protected beginTransaction(): void { /* ... */ } protected commit(): void { /* ... */ } protected rollback(): void { /* ... */ } // Event dispatching - for some async controllers protected dispatch(event: Event): void { /* ... */ } protected subscribe(handler: EventHandler): void { /* ... */ } // And on and on... // 💥 Many children don't need most of these!} class SimpleHelloController extends BaseController { // Only needs logInfo, but inherits 30+ methods and 10+ fields handle(): Response { this.logInfo("Hello endpoint called"); return this.jsonResponse({ message: "Hello" }); }}Problems with God Parents:
Deep inheritance hierarchies (more than 2-3 levels) create significant maintenance and understanding challenges. Each additional level amplifies the coupling problems of inheritance.
Why deep hierarchies form:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// ❌ BAD: Deep inheritance hierarchy // Level 1abstract class Entity { id: string; createdAt: Date;} // Level 2abstract class Auditable extends Entity { updatedAt: Date; updatedBy: string;} // Level 3abstract class Nameable extends Auditable { name: string; slug: string;} // Level 4abstract class Categorizable extends Nameable { category: Category; tags: Tag[];} // Level 5abstract class Publishable extends Categorizable { publishedAt: Date | null; status: 'draft' | 'published' | 'archived';} // Level 6abstract class ContentItem extends Publishable { body: string; author: User;} // Level 7class BlogPost extends ContentItem { featuredImage: string; comments: Comment[];} // Now imagine:// - Understanding what fields BlogPost has requires reading 7 classes// - A change to Auditable affects 6 levels of children// - Testing BlogPost requires understanding the entire hierarchy// - New requirement: "Some entities need versioning" - where to add?| Problem | 2 Levels | 4 Levels | 7 Levels |
|---|---|---|---|
| Classes to understand | 2 | 4 | 7 |
| Potential change ripple | 1 child | 3 children | 6 children |
| Debugging complexity | Low | Medium | High |
| Chance of diamond problem* | None | Possible | Likely |
| Flexibility for new requirements | Good | Moderate | Poor |
Rule of thumb: If your hierarchy is deeper than 2-3 levels, question each level. Ask:
Often, deep hierarchies can be flattened by using composition or mixins/traits for the cross-cutting concerns.
The Yo-Yo Problem occurs when understanding a class requires bouncing up and down the inheritance hierarchy, because behavior is scattered across multiple levels.
The experience:
ChildMethod() and see it calls this.parentHelper()ParentHelper() and see it calls this.grandparentSetup()this.childCallback() (back down!)parentHelper()This cognitive yo-yo is exhausting and error-prone.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// ❌ BAD: Yo-Yo inheritance abstract class GrandparentProcessor { protected data: any; process(): void { this.initialize(); // Defined here this.validate(); // Abstract - defined in child this.transform(); // Defined in Parent this.finalize(); // Abstract - defined in grandchild } protected initialize(): void { this.data = this.getData(); // Abstract - defined in grandchild this.setupHooks(); // Defined in parent } protected abstract getData(): any; protected abstract validate(): void; protected abstract setupHooks(): void; protected abstract finalize(): void;} abstract class ParentProcessor extends GrandparentProcessor { protected hooks: Function[] = []; protected transform(): void { for (const hook of this.hooks) { this.data = hook(this.data); } this.afterTransform(); // Abstract - in grandchild } protected setupHooks(): void { this.hooks = this.getHooks(); // Abstract - in grandchild } protected abstract afterTransform(): void; protected abstract getHooks(): Function[];} class ConcreteProcessor extends ParentProcessor { protected getData(): any { return { value: 1 }; } protected validate(): void { /* validate this.data */ } protected finalize(): void { /* save this.data */ } protected afterTransform(): void { /* log result */ } protected getHooks(): Function[] { return [x => x.value * 2]; }} // To understand what process() does, you must:// 1. Read GrandparentProcessor.process()// 2. Jump to ConcreteProcessor.getData()// 3. Back to ParentProcessor.setupHooks()// 4. Jump to ConcreteProcessor.getHooks()// 5. Back to ParentProcessor.validate()// ... and so on, bouncing constantlyEach class should be comprehensible by reading primarily that class. If understanding a method requires jumping between multiple inheritance levels, consider using Template Method pattern sparingly, favoring Strategy pattern (composition), or flattening the hierarchy.
Here's a summary of red flags that indicate inheritance mistakes are present or imminent:
You now recognize the most common inheritance mistakes: code-reuse-only inheritance, the Square-Rectangle trap, behavior restriction, god parents, deep hierarchies, and yo-yo code. In the final page, we'll explore how inheritance should be used to model behavioral hierarchies rather than just achieve code reuse.