Loading content...
Both interfaces and abstract classes provide abstraction—they let you define a contract that derived types must fulfill. But they serve fundamentally different purposes and offer different trade-offs.
Choosing between them isn't about which is "better"—it's about understanding what each provides and selecting the appropriate tool for each situation. Sometimes you need an interface. Sometimes an abstract class. Often, the best designs use both together.
This page will give you a clear decision framework for making this choice confidently.
By the end of this page, you will understand the fundamental differences between interfaces and abstract classes, when each is appropriate, how they can work together, and the language-specific considerations that affect your choice. You'll have a practical decision framework for real-world scenarios.
Let's start with the core distinction that determines everything else:
Interfaces define contracts — They specify WHAT capabilities something must have, without any implementation. An interface is pure abstraction.
Abstract classes provide partial implementations — They specify what SOME capabilities must look like while providing DEFAULT behavior for others. An abstract class mixes abstraction with implementation.
| Characteristic | Interface | Abstract Class |
|---|---|---|
| Implementation | None (traditionally)* | Can provide concrete methods |
| State (Fields) | Cannot have instance fields | Can have instance fields |
| Constructors | Cannot have constructors | Can have constructors |
| Multiple Inheritance | Class can implement many | Class can extend only one |
| Access Modifiers | All methods public by default | Can have protected/private members |
| Instantiable | Never directly instantiable | Never directly instantiable |
| Purpose | Define a capability contract | Define a partial template |
*Modern languages have added features that blur this distinction. TypeScript interfaces can have optional properties. Java 8+ and C# 8+ interfaces can have default method implementations. However, the conceptual distinction remains: interfaces are primarily about contracts, abstract classes are primarily about shared implementation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// ================================// INTERFACE: Pure Contract// ================================interface Flyable { // Only method signatures - no implementation fly(destination: string): void; getAltitude(): number; land(): void;} // Anyone implementing Flyable decides HOW to flyclass Bird implements Flyable { private altitude = 0; fly(destination: string): void { console.log(`Flapping wings toward ${destination}`); this.altitude = 500; } getAltitude(): number { return this.altitude; } land(): void { this.altitude = 0; }} class Airplane implements Flyable { private altitude = 0; fly(destination: string): void { console.log(`Jet engines engaging, heading to ${destination}`); this.altitude = 35000; } getAltitude(): number { return this.altitude; } land(): void { this.altitude = 0; }} // ================================// ABSTRACT CLASS: Partial Template// ================================abstract class Vehicle { // Concrete state that subclasses inherit protected speed: number = 0; protected isRunning: boolean = false; // Concrete constructor that initializes state constructor(protected make: string, protected model: string) {} // Concrete method with full implementation start(): void { this.isRunning = true; console.log(`${this.make} ${this.model} started`); } stop(): void { this.speed = 0; this.isRunning = false; console.log(`${this.make} ${this.model} stopped`); } getSpeed(): number { return this.speed; } // Abstract method - subclasses MUST implement abstract accelerate(amount: number): void; // Abstract method - subclasses MUST implement abstract brake(): void;} // Subclasses inherit state and concrete methods, implement abstract onesclass Car extends Vehicle { accelerate(amount: number): void { if (this.isRunning) { this.speed = Math.min(this.speed + amount, 200); console.log(`Car accelerating to ${this.speed} km/h`); } } brake(): void { this.speed = Math.max(0, this.speed - 30); console.log(`Car braking to ${this.speed} km/h`); }} class Motorcycle extends Vehicle { accelerate(amount: number): void { if (this.isRunning) { this.speed = Math.min(this.speed + amount * 1.5, 300); console.log(`Motorcycle accelerating to ${this.speed} km/h`); } } brake(): void { this.speed = Math.max(0, this.speed - 50); console.log(`Motorcycle braking to ${this.speed} km/h`); }} // Car and Motorcycle inherit: make, model, speed, isRunning, start(), stop(), getSpeed()// They only needed to implement: accelerate(), brake()Interfaces are the right choice when you need to define capabilities that unrelated classes might share. They answer the question: "What can this object do?" without constraining how it's done or what the object fundamentally is.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// ================================// USE CASE 1: Cross-cutting Capability// ================================ // Many unrelated things can be serializedinterface Serializable { serialize(): string; deserialize(data: string): void;} class User implements Serializable { constructor(public name: string, public email: string) {} serialize(): string { return JSON.stringify({ name: this.name, email: this.email }); } deserialize(data: string): void { const obj = JSON.parse(data); this.name = obj.name; this.email = obj.email; }} class Configuration implements Serializable { constructor(public settings: Map<string, string>) {} serialize(): string { return JSON.stringify(Object.fromEntries(this.settings)); } deserialize(data: string): void { this.settings = new Map(Object.entries(JSON.parse(data))); }} // User and Configuration have nothing in common except Serializable capability// An abstract class would make no sense here // ================================// USE CASE 2: Multiple Capabilities// ================================ interface Identifiable { id: string; }interface Timestamped { createdAt: Date; updatedAt: Date; }interface Auditable { lastModifiedBy: string; }interface Archivable { archive(): Promise<void>; restore(): Promise<void>; } // A class combining multiple capabilitiesclass Invoice implements Identifiable, Timestamped, Auditable, Archivable { id: string = crypto.randomUUID(); createdAt: Date = new Date(); updatedAt: Date = new Date(); lastModifiedBy: string = 'system'; async archive(): Promise<void> { /* ... */ } async restore(): Promise<void> { /* ... */ }} // ================================// USE CASE 3: Dependency Injection// ================================ // Service depends on interface, not implementationinterface Logger { info(message: string): void; error(message: string, error: Error): void;} class OrderService { // Accepts ANY Logger implementation constructor(private logger: Logger) {} processOrder(order: Order): void { this.logger.info(`Processing order ${order.id}`); // ... }} // Easy to swap implementationsclass ConsoleLogger implements Logger { info(message: string): void { console.log(message); } error(message: string, error: Error): void { console.error(message, error); }} class CloudWatchLogger implements Logger { info(message: string): void { /* Send to CloudWatch */ } error(message: string, error: Error): void { /* Send to CloudWatch */ }} // In production: new OrderService(new CloudWatchLogger())// In tests: new OrderService(mockLogger)Abstract classes are the right choice when you have shared implementation that should be inherited by related classes. They answer the question: "What IS this object?" and provide a template that subclasses complete.
Abstract classes represent an IS-A relationship with shared behavior.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
// ================================// USE CASE 1: Shared State and Behavior// ================================ abstract class DatabaseConnection { protected connectionString: string; protected isConnected: boolean = false; protected queryCount: number = 0; constructor(connectionString: string) { this.connectionString = connectionString; } // Concrete method - same for all databases async connect(): Promise<void> { if (this.isConnected) return; await this.doConnect(); this.isConnected = true; console.log('Connected to database'); } // Concrete method - same for all databases async disconnect(): Promise<void> { if (!this.isConnected) return; await this.doDisconnect(); this.isConnected = false; console.log(`Disconnected after ${this.queryCount} queries`); } // Abstract method - database-specific connection logic protected abstract doConnect(): Promise<void>; protected abstract doDisconnect(): Promise<void>; // Abstract method - database-specific query execution abstract executeQuery<T>(query: string, params?: unknown[]): Promise<T>;} class PostgresConnection extends DatabaseConnection { private client: PgClient | null = null; protected async doConnect(): Promise<void> { this.client = new PgClient(this.connectionString); await this.client.connect(); } protected async doDisconnect(): Promise<void> { await this.client?.end(); this.client = null; } async executeQuery<T>(query: string, params?: unknown[]): Promise<T> { this.queryCount++; const result = await this.client!.query(query, params); return result.rows as T; }} class MySQLConnection extends DatabaseConnection { private connection: MySQLConnection2 | null = null; protected async doConnect(): Promise<void> { this.connection = await mysql.createConnection(this.connectionString); } protected async doDisconnect(): Promise<void> { await this.connection?.end(); this.connection = null; } async executeQuery<T>(query: string, params?: unknown[]): Promise<T> { this.queryCount++; const [rows] = await this.connection!.execute(query, params); return rows as T; }} // ================================// USE CASE 2: Template Method Pattern// ================================ abstract class ReportGenerator { // Template method - defines the algorithm skeleton async generateReport(): Promise<Report> { const data = await this.fetchData(); const processedData = this.processData(data); const formatted = this.formatOutput(processedData); await this.saveReport(formatted); return formatted; } // Abstract steps - subclasses provide specific implementations protected abstract fetchData(): Promise<RawData>; protected abstract processData(data: RawData): ProcessedData; protected abstract formatOutput(data: ProcessedData): Report; // Concrete step with default implementation protected async saveReport(report: Report): Promise<void> { await fs.promises.writeFile( `reports/${report.id}.json`, JSON.stringify(report) ); }} class SalesReport extends ReportGenerator { protected async fetchData(): Promise<RawData> { return database.query('SELECT * FROM sales WHERE ...'); } protected processData(data: RawData): ProcessedData { // Calculate totals, averages, trends return { aggregated: this.aggregate(data) }; } protected formatOutput(data: ProcessedData): Report { return { id: crypto.randomUUID(), type: 'sales', data: data.aggregated, generatedAt: new Date() }; }} class InventoryReport extends ReportGenerator { protected async fetchData(): Promise<RawData> { return database.query('SELECT * FROM inventory WHERE ...'); } protected processData(data: RawData): ProcessedData { // Calculate stock levels, reorder points return { inventory: this.analyzeInventory(data) }; } protected formatOutput(data: ProcessedData): Report { return { id: crypto.randomUUID(), type: 'inventory', data: data.inventory, generatedAt: new Date() }; }} // ================================// USE CASE 3: Protected Utilities// ================================ abstract class BaseController { protected logger: Logger; protected validator: Validator; constructor(logger: Logger, validator: Validator) { this.logger = logger; this.validator = validator; } // Protected helper - only subclasses use this protected logRequest(action: string, userId: string): void { this.logger.info(`[${action}] User: ${userId}`); } // Protected helper - validation utility for subclasses protected validateInput<T>(schema: Schema<T>, input: unknown): T { const result = this.validator.validate(schema, input); if (!result.success) { throw new ValidationError(result.errors); } return result.data; } abstract handleRequest(request: Request): Promise<Response>;} class UserController extends BaseController { async handleRequest(request: Request): Promise<Response> { this.logRequest('getUser', request.userId); const params = this.validateInput(getUserSchema, request.params); // Handle the request... return new Response({ user }); }}Here's a practical framework for choosing between interfaces and abstract classes. Ask yourself these questions in order:
# Interface vs Abstract Class Decision Tree ┌─────────────────────────────────────────────────────────────┐│ Q1: Are the implementing classes fundamentally related? ││ (IS-A relationship vs HAS-CAPABILITY relationship) │└─────────────────────────────────────────────────────────────┘ │ ┌─────────────┴─────────────┐ │ │ ▼ ▼ ╔═══════════════╗ ╔═══════════════╗ ║ NOT RELATED ║ ║ RELATED ║ ║ → INTERFACE ║ ║ (continue) ║ ╚═══════════════╝ ╚═══════════════╝ │┌───────────────────────────────────────┴────────────────────┐│ Q2: Is there shared implementation (code or state)? │└─────────────────────────────────────────────────────────────┘ │ ┌─────────────┴─────────────┐ │ │ ▼ ▼ ╔═══════════════╗ ╔═══════════════╗ ║ NO SHARED ║ ║ YES SHARED ║ ║ → INTERFACE ║ ║ → ABSTRACT ║ ╚═══════════════╝ ╠═══════════════╣ ║ CLASS ║ ╚═══════════════╝ │┌───────────────────────────────────────┴────────────────────┐│ Q3: Might a class need to fulfill multiple such contracts? │└─────────────────────────────────────────────────────────────┘ │ ┌─────────────┴─────────────┐ │ │ ▼ ▼ ╔═══════════════╗ ╔═══════════════════════════╗ ║ YES MULTIPLE ║ ║ NO, SINGLE IS SUFFICIENT ║ ║ → INTERFACE ║ ║ → ABSTRACT CLASS ║ ║ (extract if ║ ║ (with interface exposed ║ ║ needed) ║ ║ for DI if needed) ║ ╚═══════════════╝ ╚═══════════════════════════╝The most sophisticated designs often use interfaces and abstract classes together. The interface defines the public contract; the abstract class provides a convenient base implementation that fulfills the contract.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
// ================================// The Pattern: Interface + Abstract Base + Concrete Classes// ================================ // 1. INTERFACE: The public contract// This is what clients depend on - maximum flexibilityinterface HttpClient { get<T>(url: string, options?: RequestOptions): Promise<T>; post<T>(url: string, body: unknown, options?: RequestOptions): Promise<T>; put<T>(url: string, body: unknown, options?: RequestOptions): Promise<T>; delete<T>(url: string, options?: RequestOptions): Promise<T>;} // 2. ABSTRACT CLASS: Convenient base with shared logic// Implements the interface, provides common behaviorabstract class BaseHttpClient implements HttpClient { protected baseUrl: string; protected defaultHeaders: Record<string, string>; protected timeout: number; constructor(config: HttpClientConfig) { this.baseUrl = config.baseUrl; this.defaultHeaders = config.defaultHeaders || {}; this.timeout = config.timeout || 30000; } // Concrete implementations using template method async get<T>(url: string, options?: RequestOptions): Promise<T> { return this.request<T>('GET', url, undefined, options); } async post<T>(url: string, body: unknown, options?: RequestOptions): Promise<T> { return this.request<T>('POST', url, body, options); } async put<T>(url: string, body: unknown, options?: RequestOptions): Promise<T> { return this.request<T>('PUT', url, body, options); } async delete<T>(url: string, options?: RequestOptions): Promise<T> { return this.request<T>('DELETE', url, undefined, options); } // Protected template method - subclasses implement the HTTP mechanics protected async request<T>( method: string, url: string, body?: unknown, options?: RequestOptions ): Promise<T> { const fullUrl = this.buildUrl(url); const headers = this.mergeHeaders(options?.headers); const response = await this.executeRequest(method, fullUrl, body, headers); if (!response.ok) { throw new HttpError(response.status, response.statusText); } return this.parseResponse<T>(response); } // Shared utility methods protected buildUrl(path: string): string { return `${this.baseUrl}${path}`; } protected mergeHeaders(additional?: Record<string, string>): Record<string, string> { return { ...this.defaultHeaders, ...additional }; } // Abstract - subclasses determine HOW to make HTTP calls protected abstract executeRequest( method: string, url: string, body?: unknown, headers?: Record<string, string> ): Promise<Response>; protected abstract parseResponse<T>(response: Response): Promise<T>;} // 3. CONCRETE CLASSES: Specific implementations // Uses native fetch APIclass FetchHttpClient extends BaseHttpClient { protected async executeRequest( method: string, url: string, body?: unknown, headers?: Record<string, string> ): Promise<Response> { return fetch(url, { method, headers: { 'Content-Type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(this.timeout) }); } protected async parseResponse<T>(response: Response): Promise<T> { return response.json(); }} // Uses axios libraryclass AxiosHttpClient extends BaseHttpClient { private axios: AxiosInstance; constructor(config: HttpClientConfig) { super(config); this.axios = axios.create({ baseURL: config.baseUrl, timeout: config.timeout }); } protected async executeRequest( method: string, url: string, body?: unknown, headers?: Record<string, string> ): Promise<Response> { const response = await this.axios.request({ method, url, data: body, headers }); // Convert axios response to fetch Response interface return new Response(JSON.stringify(response.data), { status: response.status, statusText: response.statusText }); } protected async parseResponse<T>(response: Response): Promise<T> { return response.json(); }} // ================================// Usage - Depends only on interface// ================================ class ApiService { // Depends on interface - can use any implementation constructor(private client: HttpClient) {} async getUsers(): Promise<User[]> { return this.client.get('/users'); } async createUser(data: CreateUserDto): Promise<User> { return this.client.post('/users', data); }} // Production: Use fetch-based clientconst apiService = new ApiService(new FetchHttpClient({ baseUrl: 'https://api.example.com', defaultHeaders: { 'Authorization': 'Bearer token' }})); // Testing: Use mock implementationclass MockHttpClient implements HttpClient { async get<T>(url: string): Promise<T> { return [] as T; } async post<T>(url: string, body: unknown): Promise<T> { return body as T; } async put<T>(url: string, body: unknown): Promise<T> { return body as T; } async delete<T>(url: string): Promise<T> { return {} as T; }} const testApiService = new ApiService(new MockHttpClient());This pattern provides the best of both worlds: clients depend on the interface for maximum flexibility and testability, while implementers can extend the abstract base class to avoid duplicating common logic. Neither is forced to use the abstract class—a client could create a completely custom implementation directly from the interface.
Understanding the differences is one thing; avoiding common pitfalls is another. Here are mistakes developers frequently make:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// ❌ MISTAKE 1: Abstract class for unrelated typesabstract class AbstractPrintable { abstract print(): void;} // Invoice and ErrorMessage aren't related - shouldn't share inheritanceclass Invoice extends AbstractPrintable { /* ... */ }class ErrorMessage extends AbstractPrintable { /* ... */ } // ✅ CORRECT: Interface for capabilityinterface Printable { print(): void;} class Invoice implements Printable { /* ... */ }class ErrorMessage implements Printable { /* ... */ } // ------------------------------------------- // ❌ MISTAKE 2: Interface when code should be sharedinterface Repository<T> { findById(id: string): Promise<T | null>; save(entity: T): Promise<T>; delete(id: string): Promise<void>;} // Every repository repeats this logging/error handlingclass UserRepository implements Repository<User> { async findById(id: string): Promise<User | null> { try { this.logger.debug(`Finding user ${id}`); return await this.db.query('...'); } catch (e) { this.logger.error(`Failed to find user ${id}`, e); throw new RepositoryError('find', e); } } // ... same pattern for save, delete} class ProductRepository implements Repository<Product> { async findById(id: string): Promise<Product | null> { try { this.logger.debug(`Finding product ${id}`); return await this.db.query('...'); } catch (e) { this.logger.error(`Failed to find product ${id}`, e); throw new RepositoryError('find', e); } } // ... same duplicated pattern} // ✅ CORRECT: Interface + Abstract base classinterface Repository<T> { findById(id: string): Promise<T | null>; save(entity: T): Promise<T>; delete(id: string): Promise<void>;} abstract class BaseRepository<T> implements Repository<T> { constructor( protected db: Database, protected logger: Logger, protected tableName: string ) {} async findById(id: string): Promise<T | null> { try { this.logger.debug(`Finding ${this.tableName} ${id}`); return await this.doFindById(id); } catch (e) { this.logger.error(`Failed to find ${this.tableName} ${id}`, e); throw new RepositoryError('find', e); } } // Subclasses only implement the DB-specific part protected abstract doFindById(id: string): Promise<T | null>; protected abstract doSave(entity: T): Promise<T>; protected abstract doDelete(id: string): Promise<void>; // ... save and delete follow same pattern} class UserRepository extends BaseRepository<User> { protected async doFindById(id: string): Promise<User | null> { return this.db.query(`SELECT * FROM users WHERE id = $1`, [id]); } // Much less code, shared error handling} // ------------------------------------------- // ❌ MISTAKE 3: Abstract class with no concrete methodsabstract class DataProcessor { abstract process(data: unknown): unknown; abstract validate(data: unknown): boolean; abstract transform(data: unknown): unknown;}// This should just be an interface! // ✅ CORRECT: Interfaceinterface DataProcessor { process(data: unknown): unknown; validate(data: unknown): boolean; transform(data: unknown): unknown;}Different languages have different capabilities for interfaces and abstract classes. Understanding these differences helps you make appropriate choices:
| Feature | TypeScript | Java 8+ | C# 8+ | Python |
|---|---|---|---|---|
| Interface Default Methods | No | Yes | Yes | N/A (has ABC) |
| Interface Fields | Yes (declarations) | Only static final | No | N/A |
| Multiple Interface Inheritance | Yes | Yes | Yes | Yes |
| Multiple Class Inheritance | No | No | No | Yes |
| Abstract Classes | Yes | Yes | Yes | Yes (ABC module) |
| Structural Typing | Yes (duck typing) | No | No | Yes |
| Traits/Mixins | Via intersection types | No (use interfaces) | No | Yes |
TypeScript uses structural typing, meaning a class automatically 'implements' an interface if it has the right shape—no explicit declaration needed. This is different from Java/C# where you must explicitly state 'implements'. This affects how you think about interfaces in TypeScript.
We've explored the fundamental differences between interfaces and abstract classes and when to use each.
Module Complete:
You've completed the module on Polymorphism Through Interfaces. You now understand:
This knowledge forms the foundation for the powerful architectural patterns covered in later chapters, including dependency injection, the Strategy pattern, and interface-based design.
Congratulations! You've mastered polymorphism through interfaces—arguably the most important form of polymorphism for building flexible, maintainable systems. The next module will explore how these concepts apply to design patterns like Strategy, and how programming to interfaces enables truly extensible architectures.