Loading content...
Interfaces define what implementations must do. But what happens when multiple implementations share not just a contract, but actual code? This is where abstract classes become essential.
Abstract classes occupy a unique position in the abstraction hierarchy: they can define contracts like interfaces while also providing shared implementation. This duality makes them a powerful tool for reducing duplication across related implementations—but it also introduces complexity. Used well, abstract classes eliminate redundancy; used poorly, they create rigid hierarchies that resist change.
By the end of this page, you will understand the distinction between interfaces and abstract classes, know when abstract class introduction is the right choice, master the Template Method pattern as the primary abstract class use case, learn strategies for incrementally introducing abstract classes into existing code, and recognize the trade-offs and risks inherent in inheritance hierarchies.
Before introducing abstract classes, let's establish a clear understanding of how they differ from interfaces and when each is appropriate.
The fundamental distinction:
| Aspect | Interface | Abstract Class |
|---|---|---|
| Implementation | None (traditionally), default methods possible | Can include full and abstract methods |
| State | Cannot hold instance state | Can have fields and state |
| Multiple inheritance | A class can implement many | A class can extend only one |
| Constructor | No constructors | Can have constructors |
| Access modifiers | All members implicitly public | Can have protected/private members |
| Primary purpose | Define a contract | Share implementation among related types |
| Coupling | Loose coupling via contract | Tighter coupling via inheritance |
| Evolution risk | Lower - adding methods (with defaults) is safer | Higher - changes affect all subclasses |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// INTERFACE: Pure contract - what you can dointerface Renderer { render(content: Content): Output; clear(): void;} // Each implementation provides ALL behaviorclass HtmlRenderer implements Renderer { render(content: Content): Output { // Complete implementation return this.buildHtmlDocument(content); } clear(): void { // Complete implementation this.buffer = ''; } // All code is in the concrete class private buffer: string = ''; private buildHtmlDocument(content: Content): Output { /* ... */ }} class PdfRenderer implements Renderer { render(content: Content): Output { // Completely different implementation return this.generatePdfBuffer(content); } clear(): void { // Different implementation this.pages = []; } private pages: PdfPage[] = []; private generatePdfBuffer(content: Content): Output { /* ... */ }} // ABSTRACT CLASS: Shared implementation + contractabstract class DocumentProcessor { // Shared state protected config: ProcessorConfig; protected logger: Logger; // Shared constructor constructor(config: ProcessorConfig) { this.config = config; this.logger = new Logger(config.logLevel); } // Shared implementation - all subclasses reuse this process(document: Document): ProcessedDocument { this.logger.log('Starting processing'); // Validation is shared across ALL document types if (!this.validate(document)) { throw new ProcessingError('Invalid document'); } // Transform is SPECIFIC to each document type const result = this.transform(document); // Post-processing is shared this.logger.log('Processing complete'); return this.finalize(result); } // Shared implementation protected validate(document: Document): boolean { return document.content.length > 0 && document.metadata != null; } // Shared implementation protected finalize(result: TransformResult): ProcessedDocument { return { ...result, processedAt: new Date(), processorVersion: this.config.version }; } // Abstract - must be implemented by subclasses protected abstract transform(document: Document): TransformResult;} // Subclasses only implement the varying partclass PdfDocumentProcessor extends DocumentProcessor { protected transform(document: Document): TransformResult { // PDF-specific transformation return this.renderToPdf(document); } private renderToPdf(document: Document): TransformResult { /* ... */ }} class HtmlDocumentProcessor extends DocumentProcessor { protected transform(document: Document): TransformResult { // HTML-specific transformation return this.buildHtmlOutput(document); } private buildHtmlOutput(document: Document): TransformResult { /* ... */ }}Use an interface when you want to say 'these types can do the same thing.' Use an abstract class when you want to say 'these types are variations of the same thing and share significant implementation.' The key word is 'share'—if implementations share code, abstract class; if they only share contract, interface.
The most common and valuable use case for abstract classes is the Template Method Pattern. This pattern defines the skeleton of an algorithm in a base class, while allowing subclasses to provide specific implementations for certain steps.
The pattern structure:
template method—a public method that outlines the algorithm's structure123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
// TEMPLATE METHOD PATTERN: Order Processing abstract class OrderProcessor { // The TEMPLATE METHOD - defines the algorithm skeleton // This is final/non-overridable in intent (TypeScript doesn't have final) processOrder(order: Order): ProcessingResult { // Step 1: Validate (INVARIANT - same for all orders) const validationResult = this.validateOrder(order); if (!validationResult.isValid) { return { success: false, error: validationResult.error }; } // Step 2: Calculate totals (VARIANT - subclass specific) const totals = this.calculateTotals(order); // Step 3: Apply discounts (VARIANT - subclass specific) const discountedTotals = this.applyDiscounts(order, totals); // Step 4: Process payment (INVARIANT - same logic) const paymentResult = this.processPayment(order, discountedTotals); if (!paymentResult.success) { return { success: false, error: paymentResult.error }; } // Step 5: Fulfill order (VARIANT - subclass specific) const fulfillmentResult = this.fulfillOrder(order); // Step 6: Send confirmation (INVARIANT - same for all) this.sendConfirmation(order, fulfillmentResult); return { success: true, result: fulfillmentResult }; } // INVARIANT STEPS - implemented in base class protected validateOrder(order: Order): ValidationResult { if (!order.items || order.items.length === 0) { return { isValid: false, error: 'Order has no items' }; } if (!order.customer) { return { isValid: false, error: 'Order has no customer' }; } return { isValid: true }; } protected processPayment(order: Order, totals: OrderTotals): PaymentResult { return this.paymentGateway.charge(order.paymentMethod, totals.grandTotal); } protected sendConfirmation(order: Order, result: FulfillmentResult): void { this.emailService.sendOrderConfirmation(order.customer.email, { orderId: order.id, trackingNumber: result.trackingNumber, estimatedDelivery: result.estimatedDelivery }); } // VARIANT STEPS - must be implemented by subclasses protected abstract calculateTotals(order: Order): OrderTotals; protected abstract applyDiscounts(order: Order, totals: OrderTotals): OrderTotals; protected abstract fulfillOrder(order: Order): FulfillmentResult; // Hook methods - optional override points (empty by default) protected afterValidation(order: Order): void { } protected beforeFulfillment(order: Order): void { } constructor( protected paymentGateway: PaymentGateway, protected emailService: EmailService ) {}} // SUBCLASS 1: Online retail orderclass RetailOrderProcessor extends OrderProcessor { protected calculateTotals(order: Order): OrderTotals { const itemTotal = order.items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ); const shipping = this.calculateShipping(order); const tax = itemTotal * this.getTaxRate(order.shippingAddress); return { itemTotal, shipping, tax, grandTotal: itemTotal + shipping + tax }; } protected applyDiscounts(order: Order, totals: OrderTotals): OrderTotals { let discount = 0; if (order.promoCode) { discount = this.promoService.calculateDiscount(order.promoCode, totals); } if (order.customer.loyaltyTier === 'gold') { discount += totals.grandTotal * 0.05; // 5% loyalty discount } return { ...totals, discount, grandTotal: totals.grandTotal - discount }; } protected fulfillOrder(order: Order): FulfillmentResult { return this.warehouse.ship(order); } constructor( paymentGateway: PaymentGateway, emailService: EmailService, private promoService: PromoService, private warehouse: WarehouseService ) { super(paymentGateway, emailService); }} // SUBCLASS 2: Digital download orderclass DigitalOrderProcessor extends OrderProcessor { protected calculateTotals(order: Order): OrderTotals { const itemTotal = order.items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ); // No shipping for digital orders! const tax = itemTotal * this.digitalTaxRate; return { itemTotal, shipping: 0, tax, grandTotal: itemTotal + tax }; } protected applyDiscounts(order: Order, totals: OrderTotals): OrderTotals { // Digital orders: bundle discounts only const bundleDiscount = this.bundleService.calculateBundleDiscount(order.items); return { ...totals, discount: bundleDiscount, grandTotal: totals.grandTotal - bundleDiscount }; } protected fulfillOrder(order: Order): FulfillmentResult { // No shipping - generate download links const downloadLinks = this.contentService.generateDownloadLinks(order.items); return { trackingNumber: null, downloadLinks, estimatedDelivery: new Date() // Immediate }; } // Override hook method for digital-specific behavior protected afterValidation(order: Order): void { // Verify all items are actually digital products for (const item of order.items) { if (!item.isDigital) { throw new Error(`Non-digital item in digital order: ${item.id}`); } } } constructor( paymentGateway: PaymentGateway, emailService: EmailService, private bundleService: BundleService, private contentService: ContentService, private digitalTaxRate: number = 0.08 ) { super(paymentGateway, emailService); }}What the pattern provides:
When to use Template Method:
Introducing an abstract class into existing code requires a careful, incremental approach. Unlike interface extraction (which adds a layer without touching implementations), abstract class introduction often requires restructuring existing classes.
The safe approach: Move in small steps, verify behavior at each step, and never combine behavioral changes with structural refactoring.
protected if they're not public.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// BEFORE: Two concrete classes with duplicated code class EmailNotifier { private logger: Logger; private config: NotifierConfig; private retryCount: number = 3; constructor(config: NotifierConfig) { this.config = config; this.logger = new Logger('EmailNotifier'); } notify(recipient: string, message: Message): NotifyResult { this.logger.log(`Sending to ${recipient}`); // Validation - SAME in both classes if (!recipient || !message) { return { success: false, error: 'Invalid input' }; } // Retry logic - SAME in both classes for (let attempt = 1; attempt <= this.retryCount; attempt++) { try { // Email-specific sending this.sendEmail(recipient, message); this.logger.log(`Sent successfully on attempt ${attempt}`); return { success: true }; } catch (e) { this.logger.log(`Attempt ${attempt} failed`); if (attempt === this.retryCount) throw e; } } } private sendEmail(recipient: string, message: Message): void { // Email-specific implementation }} class SmsNotifier { private logger: Logger; private config: NotifierConfig; private retryCount: number = 3; // DUPLICATED constructor(config: NotifierConfig) { this.config = config; this.logger = new Logger('SmsNotifier'); // NEAR-DUPLICATE } notify(recipient: string, message: Message): NotifyResult { this.logger.log(`Sending to ${recipient}`); // DUPLICATED // Validation - SAME if (!recipient || !message) { return { success: false, error: 'Invalid input' }; } // Retry logic - SAME for (let attempt = 1; attempt <= this.retryCount; attempt++) { try { // SMS-specific sending this.sendSms(recipient, message); this.logger.log(`Sent successfully on attempt ${attempt}`); return { success: true }; } catch (e) { this.logger.log(`Attempt ${attempt} failed`); if (attempt === this.retryCount) throw e; } } } private sendSms(recipient: string, message: Message): void { // SMS-specific implementation }}When introducing an abstract class, resist the temptation to 'improve' the shared code while moving it. Extract first, verify tests pass, THEN improve. Mixing refactoring with behavioral changes makes it impossible to verify that the refactoring itself was correct.
Abstract classes can provide a toolkit of protected methods that subclasses can use but external code cannot. This toolkit represents shared capabilities that aren't part of the public contract but are essential for implementations.
The access level meanings:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
abstract class DataExporter { // PUBLIC: Part of the interface/contract public async export(data: ExportData): Promise<ExportResult> { const validated = this.validate(data); const formatted = await this.format(validated); return this.write(formatted); } // ABSTRACT: Each subclass must implement protected abstract format(data: ValidatedData): Promise<FormattedData>; protected abstract write(data: FormattedData): Promise<ExportResult>; // PROTECTED TOOLKIT: Shared utilities for subclasses /** * Validates export data. Subclasses can override for custom validation. */ protected validate(data: ExportData): ValidatedData { if (!data.records || data.records.length === 0) { throw new ValidationError('No records to export'); } return { ...data, validatedAt: new Date(), recordCount: data.records.length }; } /** * Converts any value to a safe string for export. * Available to all subclasses but not to external code. */ protected sanitizeForExport(value: unknown): string { if (value === null || value === undefined) return ''; if (typeof value === 'string') return this.escapeSpecialChars(value); if (typeof value === 'number') return value.toString(); if (value instanceof Date) return value.toISOString(); return JSON.stringify(value); } /** * Chunks large datasets for memory-efficient processing. */ protected *chunkRecords<T>(records: T[], chunkSize: number): Generator<T[]> { for (let i = 0; i < records.length; i += chunkSize) { yield records.slice(i, i + chunkSize); } } /** * Logs export progress with consistent formatting. */ protected logProgress(message: string, current: number, total: number): void { const percent = Math.round((current / total) * 100); console.log(`[Export] ${message}: ${current}/${total} (${percent}%)`); } // PRIVATE: Internal to abstract class, not available to subclasses private escapeSpecialChars(str: string): string { return str.replace(/[\r\t]/g, ' ').replace(/"/g, '\"'); }} class CsvExporter extends DataExporter { protected async format(data: ValidatedData): Promise<FormattedData> { const lines: string[] = []; const headers = Object.keys(data.records[0]); lines.push(headers.join(',')); let processed = 0; for (const record of data.records) { const values = headers.map(h => // Using protected toolkit method this.sanitizeForExport(record[h]) ); lines.push(values.join(',')); processed++; if (processed % 1000 === 0) { // Using protected toolkit method this.logProgress('Formatting', processed, data.recordCount); } } return { content: lines.join(''), mimeType: 'text/csv' }; } protected async write(data: FormattedData): Promise<ExportResult> { await fs.writeFile(this.outputPath, data.content); return { success: true, path: this.outputPath }; } constructor(private outputPath: string) { super(); }} class JsonExporter extends DataExporter { protected async format(data: ValidatedData): Promise<FormattedData> { // For very large datasets, use the chunking utility const chunks: string[] = []; for (const chunk of this.chunkRecords(data.records, 1000)) { // Using protected toolkit method chunks.push(JSON.stringify(chunk)); this.logProgress('Processing chunk', chunks.length, Math.ceil(data.recordCount / 1000)); } return { content: `[${chunks.join(',')}`, mimeType: 'application/json' }; } protected async write(data: FormattedData): Promise<ExportResult> { // JSON-specific writing logic return { success: true, path: this.outputPath }; } constructor(private outputPath: string) { super(); }}Design protected methods as you would design a utility library: each method should do one thing well, have clear parameters and return values, and be useful across multiple subclasses. If a 'toolkit method' is only used by one subclass, it probably belongs in that subclass, not the base.
Abstract classes are powerful, but they come with significant risks that interfaces don't have. Understanding these risks helps you use abstract classes judiciously rather than reflexively.
The core risk: Abstract classes create tight coupling through inheritance. Subclasses depend on the base class's implementation details, not just its interface.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// RISK: Fragile Base Class Problem abstract class Vehicle { protected speed: number = 0; // Original implementation accelerate(amount: number): void { this.speed += amount; this.updateEngine(); } protected abstract updateEngine(): void;} class Car extends Vehicle { private rpm: number = 0; protected updateEngine(): void { // Assumes speed is already updated when this is called this.rpm = this.speed * 100; }} // Later, someone "optimizes" the base class:abstract class Vehicle { protected speed: number = 0; // "Optimization": only update engine if speed actually changed accelerate(amount: number): void { if (amount === 0) return; // Short-circuit this.updateEngine(); // ❌ Now called BEFORE speed update this.speed += amount; } protected abstract updateEngine(): void;} // Car.updateEngine() is now broken - it reads the OLD speed value!// This "harmless" change broke the subclass. // RISK: Single Inheritance Constraint // You have these abstract classes:abstract class Serializable { abstract serialize(): string; abstract deserialize(data: string): void;} abstract class Auditable { abstract getAuditLog(): AuditEntry[]; abstract recordAction(action: string): void;} // Problem: Order needs BOTH capabilitiesclass Order extends Serializable { // ✓ Can serialize // ❌ Cannot also extend Auditable!} // SOLUTION: Use interfaces + composition insteadinterface Serializable { serialize(): string; deserialize(data: string): void;} interface Auditable { getAuditLog(): AuditEntry[]; recordAction(action: string): void;} class Order implements Serializable, Auditable { private serializer = new JsonSerializer(); private auditLog = new AuditLog(); // Can implement both via composition serialize(): string { return this.serializer.serialize(this); } getAuditLog(): AuditEntry[] { return this.auditLog.getEntries(); } // ...}The classic advice 'favor composition over inheritance' exists because of these risks. Before using an abstract class, ask: 'Could I achieve the same code sharing through composition?' If yes, composition is usually safer. Reserve inheritance for true IS-A relationships where polymorphism through inheritance is specifically needed.
Given the risks, when IS an abstract class the right choice? There are specific scenarios where the benefits outweigh the risks.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// GOOD USE: Stream processors share substantial implementation// Template Method pattern, clear IS-A, shallow hierarchy abstract class StreamProcessor { // Significant shared state protected buffer: Buffer = Buffer.alloc(8192); protected position: number = 0; protected bytesProcessed: number = 0; // Substantial shared implementation async process(stream: Stream): Promise<ProcessResult> { while (!stream.eof()) { const chunk = await stream.read(this.buffer.length); await this.processChunk(chunk); this.bytesProcessed += chunk.length; } return this.finalize(); } protected abstract processChunk(chunk: Buffer): Promise<void>; protected abstract finalize(): ProcessResult;} class EncryptionStreamProcessor extends StreamProcessor { protected async processChunk(chunk: Buffer): Promise<void> { // Just the encryption logic }} class CompressionStreamProcessor extends StreamProcessor { protected async processChunk(chunk: Buffer): Promise<void> { // Just the compression logic }} // BAD USE: Forcing inheritance for code reuse// No IS-A relationship, better with composition // ❌ Wrong: UserService IS-A DatabaseAccessor? No!abstract class DatabaseAccessor { protected db: Database; protected executeQuery(sql: string): Promise<Result>; protected beginTransaction(): Promise<void>;} class UserService extends DatabaseAccessor { // UserService isn't a "kind of" database accessor // It just USES database access} // ✅ Right: UserService HAS-A database repositoryinterface UserRepository { findById(id: string): Promise<User>; save(user: User): Promise<void>;} class UserService { constructor(private userRepo: UserRepository) {} async getUser(id: string): Promise<User> { return this.userRepo.findById(id); }} class PostgresUserRepository implements UserRepository { constructor(private db: Database) {} async findById(id: string): Promise<User> { return this.db.query('SELECT * FROM users WHERE id = $1', [id]); }}Ask: 'Is [Subclass] a [BaseClass] in every context where [BaseClass] could be used?' If the answer is 'yes, with some caveats,' reconsider. If you need to add qualifications like 'except when...' or 'as long as...,' the inheritance relationship is questionable.
Let's consolidate the guidance for choosing between interfaces and abstract classes.
| Situation | Choose | Reason |
|---|---|---|
| Types share only a contract | Interface | No implementation to share |
| Need multiple 'behaviors' | Interfaces (multiple) | Can implement many |
| Types share 50%+ implementation | Abstract Class | Significant code reuse |
| Clear Template Method applies | Abstract Class | Pattern fit |
| Cross-module boundary | Interface | Lower coupling |
| Subclasses need protected state | Abstract Class | State sharing |
| Might swap at runtime | Interface | More flexible |
| Framework extension point | Abstract Class | If framework expects it |
What comes next:
We've covered identifying opportunities, extracting interfaces, and introducing abstract classes. The final page of this module covers iterative abstraction improvement—how to evolve abstractions over time as requirements change and understanding deepens.
You now understand when and how to introduce abstract classes—distinguishing them from interfaces, applying the Template Method pattern, introducing them incrementally, designing protected toolkits, and navigating the inherent risks. The final page covers iterative improvement of abstractions over time.