An abstract class in TypeScript can be extended by a concrete class, while an interface can only be implemented.
Let’s see this in action.
Imagine we’re building a system for processing different types of documents. We have a base concept of a Document, and then specific types like TextDocument and PdfDocument.
// Abstract class definition
abstract class Document {
constructor(public title: string) {}
// Abstract method: must be implemented by subclasses
abstract getContent(): string;
// Concrete method: can be inherited or overridden
displayTitle(): void {
console.log(`Document Title: ${this.title}`);
}
}
// Concrete class extending the abstract class
class TextDocument extends Document {
constructor(title: string, private content: string) {
super(title);
}
// Implementation of the abstract method
getContent(): string {
return this.content;
}
}
// Another concrete class extending the abstract class
class PdfDocument extends Document {
constructor(title: string, private filePath: string) {
super(title);
}
// Implementation of the abstract method
getContent(): string {
return `PDF content from ${this.filePath}`;
}
}
const textDoc = new TextDocument("My Notes", "This is the content of my text document.");
textDoc.displayTitle(); // Output: Document Title: My Notes
console.log(textDoc.getContent()); // Output: This is the content of my text document.
const pdfDoc = new PdfDocument("Annual Report", "/path/to/report.pdf");
pdfDoc.displayTitle(); // Output: Document Title: Annual Report
console.log(pdfDoc.getContent()); // Output: PDF content from /path/to/report.pdf
Now, let’s consider how an interface would work for the same problem.
// Interface definition
interface IDocument {
title: string;
getContent(): string;
// Interfaces cannot have concrete methods that are inherited
// displayTitle(): void; // This would be an error if we tried to implement it like a concrete method
}
// Class implementing the interface
class MarkdownDocument implements IDocument {
constructor(public title: string, private markdownContent: string) {}
getContent(): string {
return this.markdownContent;
}
// We can add specific methods to implementing classes
formatAsHtml(): string {
return `<h1>${this.title}</h1>\n<p>${this.markdownContent.replace(/\n/g, '<br>')}</p>`;
}
}
const markdownDoc = new MarkdownDocument("Welcome", "Hello, world!");
console.log(markdownDoc.title); // Output: Welcome
console.log(markdownDoc.getContent()); // Output: Hello, world!
console.log(markdownDoc.formatAsHtml()); // Output: <h1>Welcome</h1><p>Hello, world!</p>
The core problem both abstract classes and interfaces solve is defining a contract for what a certain type of object should be able to do, ensuring consistency and enabling polymorphism. Abstract classes are best when you want to define a base class with some shared implementation details and a blueprint for subclasses. Interfaces are ideal for defining shapes or capabilities that unrelated classes can adopt.
Abstract classes allow you to define a partial implementation. They can have constructors, concrete methods that subclasses inherit directly, and abstract methods that subclasses must implement. This is powerful when you have a common base behavior that all derived classes should share and a set of behaviors that must be specialized. For example, an AbstractShape class might have a color property and a getArea() abstract method. All shapes have a color, but the way area is calculated is specific to each shape (circle, square, triangle).
Interfaces, on the other hand, are purely a contract. They define properties and method signatures. A class implements an interface, meaning it promises to provide implementations for all the members defined in the interface. Interfaces are excellent for defining capabilities that might be mixed and matched across different class hierarchies. For instance, you might have an ILogger interface with a log(message: string) method. Many unrelated classes (a UserService, a DatabaseManager, a UIComponent) could implement ILogger to provide logging functionality without sharing any common ancestry.
A key distinction often overlooked is how they handle state and initialization. Abstract classes have constructors, which means you can initialize instance properties within the abstract class itself and pass values up the inheritance chain using super(). This is crucial when the base class needs to manage fundamental aspects of its derived classes’ state. Interfaces, by definition, do not have constructors or instance state; they only describe the shape of an object. Any state management must be handled entirely by the implementing class.
When you’re defining a base class that provides common functionality and a clear "is-a" relationship (e.g., TextDocument is a Document), an abstract class is the natural fit. When you’re defining a "can-do" capability or a contract that multiple, potentially unrelated types can fulfill (e.g., anything that CanBeSerialized should have a serialize() method), an interface is usually the better choice.
The next concept to explore is how to use these to create truly flexible systems, perhaps by leveraging union types or intersection types with interfaces.