Loading learning content...
When you declare that one class extends another, you're not just sharing code—you're establishing a relationship that has profound implications for how objects behave, how types interact, and how your system evolves over time.
The parent-child relationship in object-oriented programming is more than a technical mechanism. It's a contract that says: "Every child is also a parent, and can be used anywhere the parent is expected." This single principle cascades into everything from polymorphism to design patterns to testing strategies.
In this page, we'll dissect this relationship with surgical precision, understanding every facet of how parent and child classes interact.
By the end of this page, you will understand the full semantics of the parent-child relationship: how children depend on parents, how parents can constrain children, how constructor chaining works, and the subtle ways this relationship affects your design decisions.
The parent-child relationship in inheritance is asymmetric and permanent. Let's understand what this means.
Asymmetric:
The relationship only goes one way. The child knows about the parent and depends on it, but the parent has no knowledge of the child. This is by design:
superBut the parent:
Permanent:
Once established, the relationship cannot be changed at runtime. A Dog is always an Animal. You cannot change a class's parent after compilation. This permanence has important implications:
The asymmetry means parents should be designed to be extended. They should provide clear contracts (public/protected interfaces) for children to use. The permanence means you should carefully consider inheritance hierarchies—changing them later can be costly.
When you write class Child extends Parent, you're making several simultaneous declarations:
1. Type Declaration
Child is now a subtype of Parent. Any variable, parameter, or return type expecting a Parent will accept a Child. This is the foundation of polymorphism.
2. Structure Declaration
Child objects will contain all the fields of Parent plus any additional fields defined in Child. The memory layout includes the parent's portion.
3. Behavior Declaration
Child inherits all accessible methods from Parent. Unless overridden, calling a method on a Child instance executes the Parent's implementation.
4. Dependency Declaration
Child now depends on Parent. If Parent changes, Child may be affected. If Parent is deleted, Child cannot compile.
5. Contract Declaration
Child promises to honor the contract established by Parent. If Parent defines a method doSomething(), callers expect Child to behave compatibly when doSomething() is called.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// When you write this...class Vehicle { protected speed: number = 0; public accelerate(amount: number): void { this.speed += amount; } public getSpeed(): number { return this.speed; }} class Car extends Vehicle { private fuelLevel: number = 100; public refuel(amount: number): void { this.fuelLevel = Math.min(100, this.fuelLevel + amount); }} // ...you're declaring ALL of the following: // 1. TYPE: Car is a subtype of Vehiclefunction driveVehicle(v: Vehicle): void { v.accelerate(10);}const myCar = new Car();driveVehicle(myCar); // ✅ Car is accepted as Vehicle // 2. STRUCTURE: Car has speed (from Vehicle) + fuelLevel (its own)// Internal memory: { speed: 0, fuelLevel: 100 } // 3. BEHAVIOR: Car can accelerate (inherited from Vehicle)myCar.accelerate(20); // ✅ Uses Vehicle's implementationconsole.log(myCar.getSpeed()); // ✅ 20 // 4. DEPENDENCY: Car depends on Vehicle// If we delete Vehicle, Car cannot compile// If we change Vehicle's accelerate(), Car is affected // 5. CONTRACT: Car honors Vehicle's interface// Anyone using a Vehicle can use a Car the same wayconst vehicles: Vehicle[] = [new Vehicle(), new Car()];vehicles.forEach(v => v.accelerate(5)); // Works uniformlyWhen you create an instance of a child class, both the parent and child portions must be initialized. This happens through constructor chaining—a mandatory sequence where constructors are called from the root ancestor down to the actual class being instantiated.
The initialization order:
The super() call:
In most languages, if you don't explicitly call super(), the compiler inserts a call to the parent's no-argument constructor. If the parent doesn't have a no-argument constructor, you MUST call super() with the appropriate arguments.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
class Animal { protected name: string; protected age: number; constructor(name: string, age: number) { console.log("1. Animal constructor starts"); this.name = name; this.age = age; console.log("2. Animal constructor ends"); }} class Mammal extends Animal { protected furColor: string; constructor(name: string, age: number, furColor: string) { console.log("0. Mammal constructor starts (before super)"); // MUST call super() before using 'this' in TypeScript super(name, age); // Calls Animal's constructor console.log("3. Mammal constructor continues (after super)"); this.furColor = furColor; console.log("4. Mammal constructor ends"); }} class Dog extends Mammal { private breed: string; constructor(name: string, age: number, furColor: string, breed: string) { console.log("Start: Dog constructor begins"); super(name, age, furColor); // Calls Mammal's constructor console.log("5. Dog constructor continues (after super)"); this.breed = breed; console.log("6. Dog constructor ends"); } public describe(): string { return `${this.name} is a ${this.age}-year-old ${this.furColor} ${this.breed}`; }} // Creating a Dog - watch the constructor chainconst rex = new Dog("Rex", 3, "brown", "German Shepherd");// Output:// Start: Dog constructor begins// 0. Mammal constructor starts (before super)// 1. Animal constructor starts// 2. Animal constructor ends// 3. Mammal constructor continues (after super)// 4. Mammal constructor ends// 5. Dog constructor continues (after super)// 6. Dog constructor ends console.log(rex.describe());// "Rex is a 3-year-old brown German Shepherd"In most languages, super() must be called before accessing this (TypeScript) or must be the first statement (Java). This ensures the parent portion is fully initialized before the child attempts to use inherited members. Violating this can cause undefined behavior or compiler errors.
A well-designed parent class anticipates and enables extension. The parent defines the contract that all children must honor and provides the foundation upon which children build.
What a parent provides:
Designing extensible parents:
Not all classes should be extended. In fact, many shouldn't. But when you design a class intended for inheritance:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
/** * A well-designed parent class that anticipates extension. * Uses Template Method pattern for customizable behavior. */abstract class ReportGenerator { // === PUBLIC INTERFACE (the contract) === /** * Generates a complete report. * This is the template method - defines the algorithm. * Children should NOT override this method. */ public generateReport(data: any[]): string { const header = this.formatHeader(); const body = this.formatBody(data); const footer = this.formatFooter(); return this.assemble(header, body, footer); } // === PROTECTED INTERFACE (extension points) === /** * Override to customize the report header. * Default: returns empty string. */ protected formatHeader(): string { return ""; } /** * MUST override - formats the main content. * This is abstract because children MUST provide their own implementation. */ protected abstract formatBody(data: any[]): string; /** * Override to customize the report footer. * Default: returns timestamp. */ protected formatFooter(): string { return `Generated: ${new Date().toISOString()}`; } // === PRIVATE IMPLEMENTATION (hidden details) === private assemble(header: string, body: string, footer: string): string { const parts = [header, body, footer].filter(p => p.length > 0); return parts.join("\n\n---\n\n"); }} // Child only needs to implement required methodsclass SalesReport extends ReportGenerator { protected formatHeader(): string { return "# Quarterly Sales Report"; } protected formatBody(data: any[]): string { return data.map(item => `- ${item.product}: $${item.revenue}` ).join("\n"); } // formatFooter() uses parent's default implementation} // Another child with different customizationclass InventoryReport extends ReportGenerator { protected formatBody(data: any[]): string { return data.map(item => `| ${item.sku} | ${item.quantity} units |` ).join("\n"); } protected formatFooter(): string { return "** LOW STOCK ITEMS HIGHLIGHTED **"; }}From the child's perspective, extending a parent is both an opportunity and a responsibility. You gain capabilities but also accept constraints.
What a child can do:
super.method() to include parent's behaviorThe art of good overriding:
When you override a parent method, you're making a statement: 'My specialized version is better for this subclass.' But you must be careful:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
class BankAccount { protected balance: number; protected transactionLog: string[] = []; constructor(initialBalance: number) { this.balance = initialBalance; } public deposit(amount: number): boolean { if (amount <= 0) return false; this.balance += amount; this.log(`Deposit: +${amount}`); return true; } public withdraw(amount: number): boolean { if (amount <= 0 || amount > this.balance) return false; this.balance -= amount; this.log(`Withdrawal: -${amount}`); return true; } protected log(message: string): void { this.transactionLog.push(`[${new Date().toISOString()}] ${message}`); } public getBalance(): number { return this.balance; }} class SavingsAccount extends BankAccount { private interestRate: number; private withdrawalsThisMonth: number = 0; private readonly MAX_WITHDRAWALS = 6; constructor(initialBalance: number, interestRate: number) { super(initialBalance); // Initialize parent portion this.interestRate = interestRate; // Initialize child portion } // EXTEND: Add new functionality public applyInterest(): void { const interest = this.balance * this.interestRate; this.balance += interest; this.log(`Interest applied: +${interest.toFixed(2)}`); } // OVERRIDE + AUGMENT: Add restrictions to parent behavior public withdraw(amount: number): boolean { // Add additional check specific to savings accounts if (this.withdrawalsThisMonth >= this.MAX_WITHDRAWALS) { this.log(`Withdrawal denied: Monthly limit reached`); return false; } // Delegate to parent's implementation const success = super.withdraw(amount); // Track if successful if (success) { this.withdrawalsThisMonth++; } return success; } // EXTEND: Add new functionality specific to savings public resetMonthlyWithdrawals(): void { this.withdrawalsThisMonth = 0; this.log(`Monthly withdrawal counter reset`); }} class CheckingAccount extends BankAccount { private overdraftLimit: number; constructor(initialBalance: number, overdraftLimit: number) { super(initialBalance); this.overdraftLimit = overdraftLimit; } // OVERRIDE: Completely replace parent behavior (with good reason) public withdraw(amount: number): boolean { if (amount <= 0) return false; // Different rule: Allow overdraft up to limit const availableFunds = this.balance + this.overdraftLimit; if (amount > availableFunds) { this.log(`Withdrawal denied: Exceeds overdraft limit`); return false; } this.balance -= amount; this.log(`Withdrawal: -${amount}${this.balance < 0 ? ' (overdraft)' : ''}`); return true; }}The super keyword is the child's mechanism for explicitly referring to the parent class. It serves two critical purposes:
1. Constructor Invocation
super() calls the parent's constructor. This is how you pass initialization arguments up the hierarchy. In most languages, this must happen before you use this or as the first statement.
2. Method Invocation
super.methodName() calls the parent's version of a method, even if the child has overridden it. This is essential for augmentation—when you want to extend parent behavior rather than replace it entirely.
| Pattern | Syntax | Use Case |
|---|---|---|
| Constructor delegation | super(args) | Pass initialization to parent, must happen first |
| Method augmentation | super.method() + custom | Add behavior before or after parent's implementation |
| Conditional parent call | if (condition) super.method() | Selectively invoke parent behavior |
| Parent property access | super.propertyName (some languages) | Rarely used; usually access inherited property directly |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
class Shape { protected color: string; constructor(color: string) { this.color = color; } public describe(): string { return `A ${this.color} shape`; } public area(): number { throw new Error("Subclass must implement area()"); } public draw(): void { console.log(`Drawing ${this.describe()}`); }} class Rectangle extends Shape { private width: number; private height: number; constructor(color: string, width: number, height: number) { // Pattern 1: Constructor delegation super(color); // Initialize Shape's portion first this.width = width; this.height = height; } // Pattern 2: Complete replacement (no super call) public area(): number { return this.width * this.height; } // Pattern 3: Augmentation - enhance parent behavior public describe(): string { // Get parent's description const parentDesc = super.describe(); // Add our details return `${parentDesc} (rectangle ${this.width}x${this.height})`; } // Pattern 4: Pre-processing, then delegate public draw(): void { console.log("Setting up rectangle canvas..."); super.draw(); // Let parent handle the actual drawing message console.log("Rectangle rendering complete."); }} class Square extends Rectangle { constructor(color: string, side: number) { // Pattern 5: Adapting parameters for parent super(color, side, side); // Square is a Rectangle with equal sides } // Pattern 6: More specific description public describe(): string { // Note: This calls Rectangle.describe(), not Shape.describe() return super.describe().replace('rectangle', 'square'); }} // Demonstrationconst square = new Square("blue", 5);console.log(square.describe()); // "A blue shape (square 5x5)" console.log(square.area());// 25 square.draw();// "Setting up rectangle canvas..."// "Drawing A blue shape (square 5x5)"// "Rectangle rendering complete."When a Square calls super.describe(), it reaches Rectangle's describe(), not Shape's. There's no way to directly call a grandparent's method—you can only call the immediate parent's version. If Rectangle had called super.describe(), that would reach Shape.
Inheritance creates one of the strongest forms of coupling in object-oriented programming. Understanding this coupling is essential for making informed design decisions.
Types of dependency created by inheritance:
The fragile base class problem:
This is the most insidious issue with inheritance coupling. Consider:
The child depended on the parent's internal implementation details. When those details changed, the child broke—even though the child's code never changed.
Mitigating coupling:
Every inheritance relationship creates a permanent, compile-time binding between child and parent. You cannot swap parents at runtime. You cannot test the child without the parent. Changes to the parent ripple to all children. Use inheritance deliberately, not by default.
The most important consequence of the parent-child relationship is substitutability: anywhere a parent type is expected, a child type can be provided. This is the foundation of polymorphism and is formalized by the Liskov Substitution Principle (which we'll cover in depth in the SOLID chapters).
What substitutability means in practice:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
class Animal { public speak(): string { return "Some sound"; } public move(): string { return "Moving..."; }} class Dog extends Animal { public speak(): string { return "Woof!"; } public fetch(): string { return "Fetching ball!"; }} class Cat extends Animal { public speak(): string { return "Meow!"; } public climb(): string { return "Climbing tree!"; }} // === SUBSTITUTABILITY IN ACTION === // 1. Assignment compatibilityconst dog: Dog = new Dog();const animal: Animal = dog; // ✅ Dog substitutes for Animal // 2. Array/collection compatibilityconst zoo: Animal[] = [ new Dog(), new Cat(), new Animal(),]; // 3. Function parameter compatibilityfunction makeAnimalSpeak(animal: Animal): void { console.log(animal.speak()); // Works for any Animal subtype} makeAnimalSpeak(new Dog()); // "Woof!"makeAnimalSpeak(new Cat()); // "Meow!" // 4. Polymorphic iterationfor (const creature of zoo) { console.log(creature.speak()); // Calls appropriate override console.log(creature.move()); // Inherited from Animal // Note: Cannot call creature.fetch() or creature.climb() // because 'creature' is typed as Animal, not Dog or Cat} // 5. Return type compatibilityfunction createAnimal(type: 'dog' | 'cat'): Animal { if (type === 'dog') return new Dog(); // ✅ Dog is returnable as Animal return new Cat(); // ✅ Cat is returnable as Animal} // === THE PRACTICAL POWER === // Write general code that works with any animal:class AnimalShelter { private animals: Animal[] = []; public admit(animal: Animal): void { this.animals.push(animal); } public makeAllSpeak(): void { for (const animal of this.animals) { console.log(animal.speak()); } }} const shelter = new AnimalShelter();shelter.admit(new Dog());shelter.admit(new Cat());shelter.makeAllSpeak(); // "Woof!" then "Meow!"Why substitutability matters:
This is the true power of inheritance—not code reuse, but type reuse. You define behavior in terms of parent types, and all children automatically participate.
We've thoroughly explored how parent and child classes relate to each other. Let's consolidate the key insights:
What's next:
Now that we understand how parent and child classes relate, the next page examines exactly WHAT gets inherited—the specific mechanics of how attributes and methods transfer from parent to child, including edge cases around visibility, static members, and language-specific behaviors.
You now deeply understand the parent-child relationship in inheritance: how it's established, how the two parties interact, the responsibilities of each, and the fundamental power of type substitutability. This understanding is essential for designing sound class hierarchies.