Loading learning content...
When a child class extends a parent class, what exactly does it receive? The answer is more nuanced than 'everything.' Different language features have different inheritance rules, and understanding these rules precisely is critical for writing correct object-oriented code.
In this page, we'll create a comprehensive inventory of what gets inherited, how access modifiers affect inheritance, and the edge cases that trip up even experienced developers.
By the end of this page, you'll have precise knowledge of: which attributes are inherited and how to access them, which methods are inherited and how they can be overridden, how static members behave in hierarchies, and the special rules for constructors, private members, and final/sealed elements.
Every instance of a child class contains all attributes defined by the parent class. This is a fundamental truth: the parent's attributes exist in every child object. However, visibility (whether the child's code can access those attributes) depends on access modifiers.
Memory layout reality:
When you create a child object, the memory allocated includes:
The child is literally a superset of the parent. There is no way to create a child that is 'smaller' than its parent—you can only add attributes, never remove them.
| Parent Modifier | Present in Child Object? | Accessible from Child Code? | Accessible from Outside? |
|---|---|---|---|
| public | Yes | Yes, directly | Yes |
| protected | Yes | Yes, directly | No |
| private | Yes | No (use parent's public/protected methods) | No |
| package/internal | Yes | Only in same package/assembly | Only in same package/assembly |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
class Vehicle { // Public: inherited AND accessible public brand: string; // Protected: inherited AND accessible (from child only) protected engineType: string; // Private: inherited (exists in memory) BUT NOT accessible private vin: string; // Vehicle Identification Number constructor(brand: string, engineType: string, vin: string) { this.brand = brand; this.engineType = engineType; this.vin = vin; } // Public method to access private attribute public getVinSummary(): string { return `VIN: ${this.vin.slice(0, 4)}****`; } // Protected method - accessible in child protected getFullVin(): string { return this.vin; }} class Car extends Vehicle { public numDoors: number; constructor(brand: string, engineType: string, vin: string, numDoors: number) { super(brand, engineType, vin); this.numDoors = numDoors; } public describe(): string { // ✅ Can access public inherited attribute const b = this.brand; // ✅ Can access protected inherited attribute const e = this.engineType; // ❌ CANNOT access private inherited attribute // const v = this.vin; // Error: 'vin' is private // ✅ Can use protected method to get private data const vinForInternal = this.getFullVin(); return `${b} with ${e} engine, ${this.numDoors} doors`; } public showVin(): string { // Use inherited public method to access private attribute return this.getVinSummary(); }} // Memory layout of a Car instance:// {// brand: "Toyota" <- from Vehicle (accessible)// engineType: "V6" <- from Vehicle (accessible from Car)// vin: "1HGBH41..." <- from Vehicle (exists but not directly accessible)// numDoors: 4 <- from Car// } const myCar = new Car("Toyota", "V6", "1HGBH41JXMN109186", 4);console.log(myCar.brand); // ✅ "Toyota"console.log(myCar.describe()); // ✅ "Toyota with V6 engine, 4 doors"// console.log(myCar.engineType); // ❌ Protected - not accessible outside// console.log(myCar.vin); // ❌ Private - not accessibleA common misconception is that private attributes aren't inherited. They ARE inherited—they exist in the child's memory. The child simply cannot access them directly. The parent's methods (which are inherited) can still operate on those private attributes.
Methods follow similar visibility rules as attributes, but with an important addition: methods can be overridden—replaced with new implementations in the child class.
Method inheritance rules:
Method resolution at runtime:
When you call a method on an object, the runtime determines which implementation to execute:
This is called dynamic dispatch or virtual method invocation. It's what enables polymorphism.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
abstract class Notification { protected recipient: string; constructor(recipient: string) { this.recipient = recipient; } // Public method - inherited as-is (can be overridden) public getRecipient(): string { return this.recipient; } // Abstract method - MUST be overridden public abstract send(): boolean; // Protected method - can be used/overridden by children protected formatMessage(content: string): string { return `[Notification] ${content}`; } // Private method - NOT visible to children private logInternal(action: string): void { console.log(`Internal log: ${action}`); } // Public method that uses private method public logAction(action: string): void { this.logInternal(action); // Works - same class }} class EmailNotification extends Notification { private subject: string; constructor(recipient: string, subject: string) { super(recipient); this.subject = subject; } // MUST implement abstract method public send(): boolean { const message = this.formatMessage(this.subject); console.log(`Sending email to ${this.recipient}: ${message}`); return true; } // Override protected method - customize formatting protected formatMessage(content: string): string { return `[EMAIL] Subject: ${content}`; } // Cannot override private method - it's not visible // private logInternal(): void { } // This is a NEW method, not an override} class SMSNotification extends Notification { private phoneNumber: string; constructor(recipient: string, phoneNumber: string) { super(recipient); this.phoneNumber = phoneNumber; } // Different implementation of abstract method public send(): boolean { const message = this.formatMessage("SMS notification"); console.log(`Sending SMS to ${this.phoneNumber}: ${message}`); return true; } // Uses parent's formatMessage without override} // Demonstration of method resolutionconst email = new EmailNotification("user@example.com", "Welcome!");email.send(); // Uses EmailNotification's send()// Output: "Sending email to user@example.com: [EMAIL] Subject: Welcome!" const sms = new SMSNotification("user@example.com", "+1-555-1234");sms.send(); // Uses SMSNotification's send() + parent's formatMessage()// Output: "Sending SMS to +1-555-1234: [Notification] SMS notification" // Polymorphic usageconst notifications: Notification[] = [email, sms];for (const n of notifications) { n.send(); // Correct implementation is called based on actual type}Static members belong to the class itself, not to instances. Their behavior in inheritance contexts is different from instance members and varies by language.
General principles:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
class Counter { // Static attribute - shared across all instances protected static count: number = 0; // Static method public static increment(): void { Counter.count++; } public static getCount(): number { return Counter.count; } // Instance method that uses static public reportCount(): string { return `Count is ${Counter.count}`; }} class SpecialCounter extends Counter { // This HIDES parent's getCount, doesn't override it public static getCount(): number { return Counter.count * 2; // Returns doubled value } // Child can access protected static public static resetCount(): void { Counter.count = 0; // ✅ Accessing parent's protected static }} // Static behavior demonstrationCounter.increment();Counter.increment(); console.log(Counter.getCount()); // 2 (uses Counter's method)console.log(SpecialCounter.getCount()); // 4 (uses SpecialCounter's hiding method) // BUT: No polymorphism with staticsconst ref: typeof Counter = SpecialCounter;console.log(ref.getCount()); // 2 - Uses Counter's version, NOT SpecialCounter's! // Instance methods can access statics normallyconst sc = new SpecialCounter();console.log(sc.reportCount()); // "Count is 2" - inherited instance method works // The shared staticCounter.increment();console.log(Counter.getCount()); // 3console.log(SpecialCounter.getCount()); // 6 - still doubled// Both refer to the same underlying Counter.countWhen a child class defines a static method with the same signature as a parent's static method, it HIDES the parent's method—it doesn't override it. The method called depends on the declared type at compile time, not the runtime type. This is a frequent source of bugs when developers expect polymorphic behavior from statics.
Constructors are NOT inherited. This is one of the most important inheritance rules to understand. Each class must define its own constructors, even if they just delegate to the parent.
Why constructors aren't inherited:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
class Person { protected name: string; protected age: number; // Parent has two constructors (via overloading pattern in TS) constructor(name: string, age: number) { this.name = name; this.age = age; } // Factory method (a common alternative to multiple constructors) public static createAdult(name: string): Person { return new Person(name, 18); }} class Employee extends Person { private employeeId: string; private department: string; // Child MUST define its own constructor // Parent's constructor is NOT available for creating Employees constructor(name: string, age: number, employeeId: string, department: string) { super(name, age); // Delegate to parent this.employeeId = employeeId; this.department = department; } // Cannot call: new Employee("John", 30) // Parent's simpler constructor is not inherited // Child can define its own factory methods public static createIntern(name: string): Employee { // Interns are young, in "Training" department return new Employee(name, 18, `INTERN_${Date.now()}`, "Training"); }} // Usageconst person = new Person("Alice", 25); // ✅ Uses Person's constructorconst adult = Person.createAdult("Bob"); // ✅ Uses factory method const employee = new Employee("Carol", 30, "E123", "Engineering"); // ✅// const badEmployee = new Employee("Dave", 28); // ❌ Error: expected 4 arguments const intern = Employee.createIntern("Eve"); // ✅ Uses Employee's factoryDefault constructor behavior:
If you don't define any constructor in a child class, some languages implicitly provide a default no-argument constructor that calls the parent's no-argument constructor. But this only works if the parent HAS a no-argument constructor.
Common pattern: Constructor telescoping
Many classes provide multiple constructors where simpler ones delegate to more complex ones:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
class HttpClient { private baseUrl: string; private timeout: number; private retries: number; // Most specific constructor constructor(baseUrl: string, timeout: number = 5000, retries: number = 3) { this.baseUrl = baseUrl; this.timeout = timeout; this.retries = retries; }} class AuthenticatedHttpClient extends HttpClient { private token: string; private refreshToken: string | null; // Full constructor constructor( baseUrl: string, token: string, refreshToken: string | null = null, timeout: number = 5000, retries: number = 3 ) { super(baseUrl, timeout, retries); this.token = token; this.refreshToken = refreshToken; } // Convenience factory for token-only creation public static withToken(baseUrl: string, token: string): AuthenticatedHttpClient { return new AuthenticatedHttpClient(baseUrl, token); } // Convenience factory for full auth public static withFullAuth( baseUrl: string, token: string, refreshToken: string ): AuthenticatedHttpClient { return new AuthenticatedHttpClient(baseUrl, token, refreshToken); }}Private members deserve special attention because their behavior in inheritance is counterintuitive. Let's establish the precise rules.
Key insight: Private members ARE part of child objects, but NOT part of the child's interface.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
class SecureContainer { private secretKey: string; private accessCount: number = 0; constructor(secretKey: string) { this.secretKey = secretKey; } // Public method that uses private members public decrypt(encryptedData: string): string { this.accessCount++; return this.performDecryption(encryptedData, this.secretKey); } // Private helper - not accessible to children private performDecryption(data: string, key: string): string { // Simplified decryption logic return `Decrypted(${data}) with key length ${key.length}`; } public getAccessCount(): number { return this.accessCount; }} class AuditedSecureContainer extends SecureContainer { private auditLog: string[] = []; constructor(secretKey: string) { super(secretKey); } // Override public method - works fine public decrypt(encryptedData: string): string { // Cannot access this.secretKey - it's private in parent // Cannot access this.accessCount - it's private in parent // Cannot call this.performDecryption() - it's private in parent // BUT we can call the inherited public method const result = super.decrypt(encryptedData); // And add our own behavior this.auditLog.push(`Decryption performed: ${new Date().toISOString()}`); return result; } // Child's own method public getAuditLog(): string[] { return [...this.auditLog]; } // This method demonstrates what child CAN access public demonstrate(): void { // ❌ console.log(this.secretKey); // Error: private // ❌ console.log(this.accessCount); // Error: private // ❌ this.performDecryption("x","y"); // Error: private // ✅ Can use inherited public methods console.log(this.getAccessCount()); // Works! } // If child defines a "private" method with same name as parent's, // it's a COMPLETELY SEPARATE method, not an override private performDecryption(data: string, key: string): string { // This is child's own private method // Parent's performDecryption is still called by parent's decrypt() return "Child's unrelated method"; }} // Usageconst container = new AuditedSecureContainer("my-secret-key");console.log(container.decrypt("sensitive data"));// Parent's logic runs (including parent's private performDecryption)// Child's audit logging is added console.log(container.getAccessCount()); // 1 - private accessCount was incrementedParents can explicitly prevent certain members from being overridden using final (Java, Kotlin), sealed (C#), or readonly properties in some contexts.
Why prevent overriding?
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
public class Transaction { private double amount; private boolean processed = false; public Transaction(double amount) { this.amount = amount; } // This method MUST NOT be overridden - it's critical for correctness public final void process() { if (processed) { throw new IllegalStateException("Already processed!"); } validate(); // Can be overridden executeCore(); // Can be overridden processed = true; // This MUST happen logCompletion(); // Can be overridden } // Override points for children protected void validate() { if (amount <= 0) { throw new IllegalArgumentException("Invalid amount"); } } protected void executeCore() { System.out.println("Processing transaction: $" + amount); } protected void logCompletion() { System.out.println("Transaction complete"); } // Final getter - value cannot be changed by overriding public final double getAmount() { return amount; }} public class RefundTransaction extends Transaction { public RefundTransaction(double amount) { super(-amount); // Refunds are negative amounts conceptually } // ✅ Can override this @Override protected void validate() { // Different validation for refunds if (getAmount() >= 0) { throw new IllegalArgumentException("Refund must be negative"); } } // ❌ Cannot override this - compile error // @Override // public void process() { } // ❌ Cannot override this - compile error // @Override // public double getAmount() { return 0; } // ✅ Can override other methods @Override protected void logCompletion() { System.out.println("Refund processed: $" + Math.abs(getAmount())); }}Understanding the mechanics is essential, but applying them effectively requires recognizing common patterns.
Pattern 1: Protected Factory Methods
Parents provide protected methods for creating objects that children might need:
123456789101112131415161718192021
abstract class LoggerBase { protected abstract createFormatter(): MessageFormatter; protected abstract createWriter(): LogWriter; public log(message: string): void { const formatter = this.createFormatter(); const writer = this.createWriter(); const formatted = formatter.format(message); writer.write(formatted); }} class ConsoleLogger extends LoggerBase { protected createFormatter(): MessageFormatter { return new TimestampFormatter(); } protected createWriter(): LogWriter { return new ConsoleWriter(); }}Pattern 2: Protected Configuration
Parents expose protected attributes that children can modify during initialization:
123456789101112131415161718192021222324252627282930313233343536
class HttpClient { protected timeout: number = 5000; protected retries: number = 3; protected baseUrl: string; constructor(baseUrl: string) { this.baseUrl = baseUrl; this.configure(); // Called after construction, before use } // Children override to customize protected configure(): void { // Default configuration - children can override } public async fetch(path: string): Promise<Response> { // Uses timeout and retries return fetch(`${this.baseUrl}${path}`, { /* ... options using protected config */ }); }} class FastHttpClient extends HttpClient { protected configure(): void { this.timeout = 1000; // Faster timeout this.retries = 1; // Fewer retries }} class ResilientHttpClient extends HttpClient { protected configure(): void { this.timeout = 30000; // Long timeout this.retries = 10; // Many retries }}We've thoroughly mapped the inheritance landscape for attributes and methods. Here's the complete picture:
What's next:
Now that we understand the mechanics of what gets inherited, the final page of this module examines inheritance as specialization—the conceptual model that should guide your use of inheritance. We'll explore when and why to create hierarchies, and how to recognize when inheritance is (and isn't) the right tool.
You now have comprehensive knowledge of attribute and method inheritance: what's inherited, what's accessible, how access modifiers interact with inheritance, and the special cases for static and final members. This precise understanding prevents subtle bugs and enables effective inheritance hierarchies.