Loading content...
When a child class overrides a parent method, the simplest approach is complete replacement—the child's implementation runs instead of the parent's. But in many scenarios, you want to extend the parent's behavior: do what the parent does, plus something more.
This is where method chaining with super becomes powerful. By calling super.method() from within your overriding method, you can build upon the parent's implementation rather than discarding it. This pattern enables layered, composable behavior across inheritance hierarchies.
Method chaining transforms inheritance from a mechanism of replacement into a mechanism of collaboration—each level of the hierarchy contributes its piece to a larger whole.
This page covers method chaining patterns: when and how to call super.method(), the placement choices (before, after, or around), the Template Method pattern that structures these calls, and real-world examples of layered behavior extension.
Method overriding creates a fork in behavior: when the method is called, which implementation runs? With complete replacement, only the child's code executes. With extension, both run—in a controlled order.
Three Fundamental Approaches:
Most real-world inheritance uses extension—complete replacement often indicates a design smell.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Three approaches to method overriding class Processor { process(data: string): string { console.log("Base: validating data"); // Base processing: sanitize and validate return data.trim().toLowerCase(); }} // APPROACH 1: Complete Replacement (often a code smell)class ReplacingProcessor extends Processor { process(data: string): string { // Completely ignores what parent does // Is this really the same "process" operation? console.log("Custom: doing something entirely different"); return data.split('').reverse().join(''); }} // APPROACH 2: Extension (the common case)class ExtendingProcessor extends Processor { process(data: string): string { // Get parent's processed result const baseResult = super.process(data); // Add our own enhancement console.log("Extended: adding prefix"); return `[PROCESSED] ${baseResult}`; }} // APPROACH 3: Selective Delegationclass ConditionalProcessor extends Processor { constructor(private useBaseProcessing: boolean) { super(); } process(data: string): string { if (this.useBaseProcessing) { // Delegate to parent in some cases return super.process(data); } else { // Use custom logic in others console.log("Custom: alternative processing"); return data.toUpperCase(); } }}If a child method completely ignores the parent's implementation, ask yourself: Is this really the same operation? A Dog that 'speaks' by returning a tax calculation isn't extending Animal—it's violating the Liskov Substitution Principle. Complete replacement should be rare and well-justified.
Where you place the super.method() call determines the order of execution and, consequently, how the behaviors combine. The three main patterns—before, after, and around—serve different purposes.
Understanding the Flow:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Pattern demonstrations: where to place super.method() class EventHandler { handle(event: Event): void { console.log(`Processing event: ${event.type}`); // Base handling logic }} // PATTERN: Pre-call (child enhances parent's output)// super runs FIRST, child SECONDclass LoggingEventHandler extends EventHandler { handle(event: Event): void { super.handle(event); // Do the base handling first console.log(`Event ${event.type} handled successfully`); // Logging comes AFTER the actual handling }} // PATTERN: Post-call (child prepares for parent)// Child runs FIRST, super SECONDclass ValidatingEventHandler extends EventHandler { handle(event: Event): void { // Validate BEFORE calling parent if (!event.type) { throw new Error("Event type is required"); } console.log("Validation passed"); super.handle(event); // Now safe to proceed }} // PATTERN: Around (wrap parent behavior)// Child runs, then super, then child againclass MetricsEventHandler extends EventHandler { handle(event: Event): void { const startTime = Date.now(); console.log("Starting event handling..."); try { super.handle(event); // Core logic in the middle } finally { const duration = Date.now() - startTime; console.log(`Event handling took ${duration}ms`); } }} interface Event { type: string;}| Placement | When to Use | Examples |
|---|---|---|
| super() first | When child enhances or reacts to parent's result | Logging, notification, additional formatting |
| super() last | When child must prepare or validate before parent runs | Validation, authentication, parameter transformation |
| super() middle | When child needs to wrap parent's execution | Timing/metrics, transactions, try-catch-finally |
| No super() | When child completely replaces parent (use sparingly) | Rare—consider if inheritance is appropriate |
The Template Method Pattern is a formal design pattern that structures super/child cooperation. The parent class defines an algorithm's skeleton, with some steps implemented and others left abstract for children to provide.
How It Works:
templateMethod() that calls other methods in a specific orderThis pattern inverts the usual super.method() calling: instead of children calling parents, the parent's template calls overridden child methods.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// Template Method Pattern - Parent controls the algorithm abstract class DataProcessor { // The TEMPLATE METHOD - defines the algorithm skeleton // Children should NOT override this method public processData(input: string): string { // Step 1: Always validate first this.validate(input); // Step 2: Parse (customizable by children) const parsed = this.parse(input); // Step 3: Transform (customizable by children) const transformed = this.transform(parsed); // Step 4: Always format output at the end return this.format(transformed); } // Concrete step - same for all children protected validate(input: string): void { if (!input || input.length === 0) { throw new Error("Input cannot be empty"); } console.log("Validation passed"); } // Abstract step - MUST be implemented by children protected abstract parse(input: string): object; // Hook method - CAN be overridden, has default protected transform(data: object): object { // Default: no transformation return data; } // Concrete step - same for all children protected format(data: object): string { return JSON.stringify(data, null, 2); }} // Child only implements/overrides what's neededclass JSONProcessor extends DataProcessor { protected parse(input: string): object { return JSON.parse(input); // Must implement abstract method } // Uses default transform and format} class CSVProcessor extends DataProcessor { protected parse(input: string): object { const lines = input.trim().split(''); const headers = lines[0].split(','); return lines.slice(1).map(line => { const values = line.split(','); return headers.reduce((obj, header, i) => { obj[header.trim()] = values[i]?.trim(); return obj; }, {} as Record<string, string>); }); } // Override the hook to add custom transformation protected transform(data: object): object { // Add metadata to CSV data return { data, processedAt: new Date().toISOString() }; }}Template Method and super-chaining are complementary. Template Method lets the parent control the algorithm and children fill in pieces. Super-chaining lets children control their execution while incorporating parent behavior. Choose based on who should control the overall flow.
In multi-level hierarchies, method chains can propagate through every level. Each class adds its behavior and delegates to its parent, creating a pipeline of accumulated behaviors.
The Propagation Effect:
When every level calls super.method(), behavior accumulates. A method call at the bottom of the hierarchy triggers a cascade up to the root, with each level contributing. The order of contributions depends on whether super is called before or after local code.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// Method chaining through a deep hierarchy class Component { render(): string[] { return ["<component>"]; } initialize(): void { console.log("Component: base initialization"); } cleanup(): void { console.log("Component: base cleanup"); }} class InteractiveComponent extends Component { render(): string[] { const parent = super.render(); // Get parent's rendering return [...parent, "<event-handlers>"]; // Add our layer } initialize(): void { super.initialize(); // Initialize parent FIRST console.log("Interactive: attaching event listeners"); } cleanup(): void { console.log("Interactive: removing event listeners"); super.cleanup(); // Clean up parent LAST (reverse order) }} class DraggableComponent extends InteractiveComponent { render(): string[] { const parent = super.render(); return [...parent, "<drag-handles>"]; } initialize(): void { super.initialize(); console.log("Draggable: setting up drag zone"); } cleanup(): void { console.log("Draggable: releasing drag handlers"); super.cleanup(); }} class ResizableComponent extends DraggableComponent { render(): string[] { const parent = super.render(); return [...parent, "<resize-handles>"]; } initialize(): void { super.initialize(); console.log("Resizable: attaching resize observers"); } cleanup(): void { console.log("Resizable: disconnecting observers"); super.cleanup(); }} // Democonst widget = new ResizableComponent(); console.log("=== RENDER (building up) ===");console.log(widget.render());// Output: ["<component>", "<event-handlers>", "<drag-handles>", "<resize-handles>"] console.log("=== INITIALIZE (parent first) ===");widget.initialize();// Output:// Component: base initialization// Interactive: attaching event listeners// Draggable: setting up drag zone// Resizable: attaching resize observers console.log("=== CLEANUP (child first) ===");widget.cleanup();// Output:// Resizable: disconnecting observers// Draggable: releasing drag handlers// Interactive: removing event listeners// Component: base cleanupNotice the symmetry: initialization calls super FIRST (parent initializes before child), while cleanup calls super LAST (child cleans up before parent). This maintains the invariant that dependencies are set up before use and remain valid until children are done.
Method chaining with super appears throughout real-world codebases. Let's examine concrete examples from common domains.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// EXAMPLE 1: UI Framework - toString() for debuggingclass Widget { constructor(protected id: string) {} toString(): string { return `Widget[${this.id}]`; }} class Button extends Widget { constructor(id: string, private label: string) { super(id); } toString(): string { return `${super.toString()}(label="${this.label}")`; }} class IconButton extends Button { constructor(id: string, label: string, private icon: string) { super(id, label); } toString(): string { return `${super.toString()}(icon=${this.icon})`; }} const btn = new IconButton("btn1", "Save", "💾");console.log(btn.toString());// Output: Widget[btn1](label="Save")(icon=💾) // EXAMPLE 2: Configuration Builder - accumulating settingsclass ServerConfig { protected config: Record<string, any> = {}; configure(): void { this.config.host = "localhost"; this.config.port = 8080; } getConfig(): Record<string, any> { return { ...this.config }; }} class SecureServerConfig extends ServerConfig { configure(): void { super.configure(); // Get base settings this.config.ssl = true; this.config.certPath = "/etc/ssl/cert.pem"; }} class ProductionServerConfig extends SecureServerConfig { configure(): void { super.configure(); // Get secure settings this.config.host = "0.0.0.0"; // Override base this.config.maxConnections = 10000; this.config.logging = "warn"; }} // EXAMPLE 3: Data Validation - layered rulesclass Validator { protected errors: string[] = []; validate(data: any): boolean { this.errors = []; // Reset errors // Base validation always runs if (data === null || data === undefined) { this.errors.push("Data cannot be null"); } return this.errors.length === 0; } getErrors(): string[] { return [...this.errors]; }} class StringValidator extends Validator { validate(data: any): boolean { super.validate(data); // Run parent checks first if (typeof data !== 'string') { this.errors.push("Must be a string"); } return this.errors.length === 0; }} class EmailValidator extends StringValidator { validate(data: any): boolean { super.validate(data); // Run string checks first if (typeof data === 'string' && !data.includes('@')) { this.errors.push("Must contain @"); } return this.errors.length === 0; }}While method chaining is common, there are legitimate cases where you intentionally don't call super. Understanding these exceptions prevents over-mechanistic application of patterns.
Legitimate Reasons to Skip super:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Cases where NOT calling super is correct // CASE 1: Abstract methods (no super to call)abstract class Shape { abstract calculateArea(): number; // No implementation to call} class Circle extends Shape { constructor(private radius: number) { super(); } calculateArea(): number { // No super.calculateArea() - there is none return Math.PI * this.radius ** 2; }} // CASE 2: Default behavior meant to be replacedclass DataSource { // Documentation: Override to provide real data source // This method is intentionally meant to be completely replaced fetchData(): string[] { return ["default", "placeholder", "data"]; }} class DatabaseSource extends DataSource { fetchData(): string[] { // Intentionally doesn't call super - we're replacing, not extending return this.queryDatabase(); } private queryDatabase(): string[] { return ["real", "database", "results"]; }} // CASE 3: Stop-the-chain (conditional termination)class EventPropagator { propagate(event: Event): boolean { console.log("BaseEventPropagator: propagating"); return true; // Indicates event should continue }} class SecurityFilter extends EventPropagator { propagate(event: Event): boolean { if (!this.isEventAllowed(event)) { console.log("SecurityFilter: BLOCKED - stopping propagation"); return false; // Intentionally don't call super } return super.propagate(event); // Only continue if allowed } private isEventAllowed(event: Event): boolean { return event.type !== 'dangerous'; }} interface Event { type: string; }If you don't call super and it's not immediately obvious why (like abstract methods), add a comment explaining the rationale. Future maintainers (including yourself) will appreciate understanding whether skipping super was intentional or an oversight.
Method chaining, despite its power, introduces several potential problems. Understanding these pitfalls helps you design safer hierarchies.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// PITFALL 1: Ignoring super's return valueclass Calculator { calculate(x: number): number { return x * 2; }} class BrokenChild extends Calculator { calculate(x: number): number { super.calculate(x); // BUG: Ignoring the return value! return x * 3; // Returns 3x, not 6x as might be expected }} class CorrectChild extends Calculator { calculate(x: number): number { const parentResult = super.calculate(x); // Capture the result return parentResult * 3; // 6x as expected (2x * 3) }} // PITFALL 2: Inconsistent ordering across methodsclass InconsistentClass { methodA(): void { super.methodA?.(); // Parent first console.log("Child A"); } methodB(): void { console.log("Child B"); super.methodB?.(); // Parent last - inconsistent! } // This makes behavior hard to predict} // PITFALL 3: State mutation with unclear orderingclass StatefulParent { protected counter: number = 0; increment(): void { this.counter++; console.log("Parent incremented to:", this.counter); }} class ConfusingChild extends StatefulParent { increment(): void { this.counter += 10; // Modifying BEFORE super console.log("Child pre-modified to:", this.counter); super.increment(); // Parent adds 1 this.counter *= 2; // Modifying AFTER super console.log("Child post-modified to:", this.counter); }} const confusing = new ConfusingChild();confusing.increment();// Output:// Child pre-modified to: 10// Parent incremented to: 11// Child post-modified to: 22// This is very hard to follow!Method chaining with super transforms inheritance from replacement to collaboration. Let's consolidate the key insights:
What's Next:
We've covered accessing parent members, constructor chaining, and method chaining. The final piece is understanding the complete picture of initialization order—exactly what happens, in what sequence, when an object in an inheritance hierarchy is created. This knowledge prevents subtle bugs and enables confident inheritance design.
You now understand method chaining with super—how to extend parent behavior rather than replace it. You know the placement patterns, the Template Method design pattern, and how chains propagate through hierarchies. Next, we'll examine the complete initialization order of objects in inheritance hierarchies.