4.2 Abstract Classes and Interface Implementation
An abstract class is a class that cannot be instantiated directly. It is a mechanism for enforcing the contract "every subclass that inherits from this class must implement these methods." It looks similar to an interface, but there are important differences. An abstract class can contain actual implementation code and can have a constructor and state (properties).
This chapter sharpens your design skills by clearly distinguishing abstract classes from interfaces, and by working through multiple interface implementations, the Template Method pattern, and practical examples — a shape hierarchy and a report generator.
Abstract Class Syntax
Adding the abstract keyword before a class declaration makes it an abstract class. Abstract methods are declared with a signature only (no body) and must be implemented in every subclass.
abstract class Shape {
// Concrete method: includes an implementation (shared by subclasses)
describe(): string {
return `The area of this shape is ${this.area()}.`;
}
// Abstract methods: no implementation — subclasses must provide one
abstract area(): number;
abstract perimeter(): number;
abstract name(): string;
}
// new Shape(); // Error: cannot instantiate an abstract class
class Circle extends Shape {
constructor(private radius: number) {
super();
}
area(): number {
return Math.PI * this.radius ** 2;
}
perimeter(): number {
return 2 * Math.PI * this.radius;
}
name(): string {
return "Circle";
}
}
const circle = new Circle(5);
console.log(circle.name()); // Circle
console.log(circle.area().toFixed(2)); // 78.54
console.log(circle.describe()); // The area of this shape is 78.53981633974483.
Leaving any abstract method unimplemented in a subclass causes a compile error.
class BadShape extends Shape {
area(): number { return 0; }
// Error: perimeter() and name() are not implemented
// Non-abstract class 'BadShape' does not implement inherited abstract member 'perimeter'
}
abstract vs interface — When to Use Which?
The selection criteria are clear.
Choose an abstract class when
- You need to share common implementation logic (method bodies) with subclasses
- Constructor logic is required
- You have state (properties) that subclasses should inherit
- The "is-a" relationship is clear (Circle is a Shape)
- An inheritance hierarchy is needed
Choose an interface when
- You only need to define a contract, with no implementation
- You want to express a capability that spans multiple inheritance hierarchies
- Multiple implementation is needed (one class implementing several interfaces)
- You want to express a "can-do" relationship (Printable, Serializable)
- You are defining an integration point with an external library
// Interface: expressing capability / contract
interface Printable {
print(): void;
}
interface Exportable {
exportToPDF(): Buffer;
exportToCSV(): string;
}
// Abstract class: providing a common base implementation
abstract class BaseReport {
protected title: string;
protected createdAt: Date;
constructor(title: string) {
this.title = title;
this.createdAt = new Date();
}
// Shared implementation
protected formatHeader(): string {
return `=== ${this.title} (${this.createdAt.toLocaleDateString()}) ===`;
}
// Forced implementation in subclasses
abstract generateContent(): string;
}
// Using an abstract class and interfaces together
class SalesReport extends BaseReport implements Printable, Exportable {
constructor(private data: { product: string; sales: number }[]) {
super("Monthly Sales Report");
}
generateContent(): string {
return this.data
.map((d) => `${d.product}: ${d.sales} units`)
.join("\n");
}
print(): void {
console.log(this.formatHeader());
console.log(this.generateContent());
}
exportToPDF(): Buffer {
const content = this.generateContent();
return Buffer.from(content); // in practice, use a PDF library
}
exportToCSV(): string {
return this.data.map((d) => `${d.product},${d.sales}`).join("\n");
}
}
Multiple Interface Implementation and the Interface Segregation Principle (ISP)
A TypeScript class can implement multiple interfaces at the same time. The Interface Segregation Principle states that clients should not be forced to depend on methods they do not use — so keep interfaces small and focused.
// Bad example: one giant interface (ISP violation)
interface BadWorker {
work(): void;
eat(): void;
sleep(): void;
drive(): void; // robots don't eat or sleep
}
// Good example: small, segregated interfaces
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
interface Drivable {
drive(): void;
}
// Human: implements all capabilities
class Human implements Workable, Eatable, Sleepable, Drivable {
work(): void { console.log("Working hard."); }
eat(): void { console.log("Enjoying a meal."); }
sleep(): void { console.log("Sleeping soundly."); }
drive(): void { console.log("Driving a car."); }
}
// Robot: implements only the capabilities it needs
class WorkRobot implements Workable, Drivable {
work(): void { console.log("Robot is working."); }
drive(): void { console.log("Robot is moving."); }
}
// Functions only require the capability they actually need
function sendToWork(worker: Workable): void {
worker.work();
}
const human = new Human();
const robot = new WorkRobot();
sendToWork(human); // OK
sendToWork(robot); // OK
Template Method Pattern
This is the most powerful usage pattern for abstract classes. The algorithm's skeleton is defined in the abstract class, and subclasses implement the individual steps. The overall flow stays the same, but each step's concrete behavior differs per subclass.
abstract class DataProcessor {
// Template method: defines the algorithm skeleton (treat as final — avoid overriding)
process(rawData: string): string {
const validated = this.validate(rawData);
const parsed = this.parse(validated);
const transformed = this.transform(parsed);
return this.format(transformed);
}
// Shared implementation (subclasses may override if needed)
protected validate(data: string): string {
if (!data || data.trim() === "") {
throw new Error("Cannot process empty data.");
}
return data.trim();
}
// Abstract steps: must be implemented
protected abstract parse(data: string): Record<string, unknown>[];
protected abstract transform(data: Record<string, unknown>[]): Record<string, unknown>[];
// Default implementation provided (may be overridden)
protected format(data: Record<string, unknown>[]): string {
return JSON.stringify(data, null, 2);
}
}
class CSVProcessor extends DataProcessor {
protected parse(data: string): Record<string, unknown>[] {
const lines = data.split("\n");
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, unknown>);
});
}
protected transform(data: Record<string, unknown>[]): Record<string, unknown>[] {
// Remove empty rows
return data.filter((row) =>
Object.values(row).some((v) => v !== "")
);
}
}
class JSONProcessor extends DataProcessor {
protected parse(data: string): Record<string, unknown>[] {
return JSON.parse(data);
}
protected transform(data: Record<string, unknown>[]): Record<string, unknown>[] {
// Convert all string values to lowercase
return data.map((row) =>
Object.fromEntries(
Object.entries(row).map(([k, v]) => [
k,
typeof v === "string" ? v.toLowerCase() : v,
])
)
);
}
}
const csvData = `name,age,city
Alice,30,Seattle
Bob,25,Portland
`;
const jsonData = `[{"name":"Charlie","age":35,"city":"Denver"}]`;
const csvProcessor = new CSVProcessor();
const jsonProcessor = new JSONProcessor();
console.log(csvProcessor.process(csvData));
console.log(jsonProcessor.process(jsonData));
Practical Example: Shape Hierarchy and Report Generator
Shape Hierarchy
abstract class Shape {
abstract area(): number;
abstract perimeter(): number;
abstract name(): string;
// Shared implementation: print shape info
describe(): void {
console.log(
`[${this.name()}] Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`
);
}
// Shared utility
isLargerThan(other: Shape): boolean {
return this.area() > other.area();
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
if (radius <= 0) throw new Error("Radius must be positive.");
}
area(): number { return Math.PI * this.radius ** 2; }
perimeter(): number { return 2 * Math.PI * this.radius; }
name(): string { return "Circle"; }
get diameter(): number { return this.radius * 2; }
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super();
if (width <= 0 || height <= 0) throw new Error("Width and height must be positive.");
}
area(): number { return this.width * this.height; }
perimeter(): number { return 2 * (this.width + this.height); }
name(): string { return "Rectangle"; }
isSquare(): boolean { return this.width === this.height; }
}
class Triangle extends Shape {
constructor(
private a: number,
private b: number,
private c: number
) {
super();
if (a + b <= c || a + c <= b || b + c <= a) {
throw new Error("Side lengths do not satisfy the triangle inequality.");
}
}
// Heron's formula
area(): number {
const s = (this.a + this.b + this.c) / 2;
return Math.sqrt(s * (s - this.a) * (s - this.b) * (s - this.c));
}
perimeter(): number { return this.a + this.b + this.c; }
name(): string { return "Triangle"; }
}
// Polymorphism in action
const shapes: Shape[] = [
new Circle(7),
new Rectangle(4, 6),
new Triangle(3, 4, 5),
];
shapes.forEach((s) => s.describe());
// Find the largest shape
const largest = shapes.reduce((max, s) => (s.isLargerThan(max) ? s : max));
console.log(`\nLargest shape: ${largest.name()} (${largest.area().toFixed(2)})`);
// Total area
const totalArea = shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(`Total area: ${totalArea.toFixed(2)}`);
Report Generator
interface ReportData {
title: string;
sections: { heading: string; content: string }[];
}
abstract class ReportGenerator {
abstract generate(data: ReportData): string;
protected buildHeader(title: string): string {
return this.wrap(title, "header");
}
protected buildSection(heading: string, content: string): string {
return this.wrap(heading, "section") + "\n" + content;
}
protected abstract wrap(text: string, type: "header" | "section"): string;
// Template method
render(data: ReportData): string {
const header = this.buildHeader(data.title);
const body = data.sections
.map((s) => this.buildSection(s.heading, s.content))
.join(this.getSeparator());
return [header, body].join("\n\n");
}
protected getSeparator(): string {
return "\n\n";
}
}
class MarkdownReportGenerator extends ReportGenerator {
generate(data: ReportData): string {
return this.render(data);
}
protected wrap(text: string, type: "header" | "section"): string {
return type === "header" ? `# ${text}` : `## ${text}`;
}
}
class HTMLReportGenerator extends ReportGenerator {
generate(data: ReportData): string {
return `<!DOCTYPE html>\n<html>\n<body>\n${this.render(data)}\n</body>\n</html>`;
}
protected wrap(text: string, type: "header" | "section"): string {
return type === "header" ? `<h1>${text}</h1>` : `<h2>${text}</h2>`;
}
protected getSeparator(): string {
return "\n<hr/>\n";
}
}
const reportData: ReportData = {
title: "Q4 2025 Performance Report",
sections: [
{ heading: "Revenue Overview", content: "15% growth year-over-year" },
{ heading: "Key Achievements", content: "Acquired 230 new customers" },
],
};
const mdGen = new MarkdownReportGenerator();
const htmlGen = new HTMLReportGenerator();
console.log(mdGen.generate(reportData));
console.log(htmlGen.generate(reportData));
Pro Tips
Abstract Constructor Type abstract new()
When using an abstract class itself as a type, use the concrete constructor type rather than new() directly.
abstract class Plugin {
abstract name(): string;
abstract execute(): void;
}
class LogPlugin extends Plugin {
name(): string { return "LogPlugin"; }
execute(): void { console.log("Logging..."); }
}
// Function that accepts an abstract class as an argument.
// Because TypeScript doesn't directly support abstract new(),
// use a concrete constructor type instead:
type ConcreteOf<T extends Plugin> = new () => T;
function loadPlugin<T extends Plugin>(PluginClass: ConcreteOf<T>): T {
const plugin = new PluginClass();
console.log(`Plugin loaded: ${plugin.name()}`);
return plugin;
}
const plugin = loadPlugin(LogPlugin);
plugin.execute(); // Logging...
Limits of Abstract Static Methods
TypeScript does not support abstract static methods. This is by design.
abstract class Registry {
// abstract static create(): Registry; // Error: not supported
// Alternative 1: express the static contract via an interface
static create?(): Registry;
// Alternative 2: define a separate factory interface
}
interface RegistryFactory {
new(): Registry;
create(): Registry;
}
// Checking a class that has a static method
function useFactory(Factory: RegistryFactory): Registry {
return Factory.create();
}
Building Hierarchies with Interface Inheritance
Interfaces can also use extends to build an inheritance hierarchy.
interface Animal {
name: string;
breathe(): void;
}
interface Pet extends Animal {
owner: string;
play(): void;
}
interface ServiceAnimal extends Animal {
license: string;
performDuty(): void;
}
// Extending two interfaces at once
interface ServicePet extends Pet, ServiceAnimal {
isRetired: boolean;
}
class GuideDog implements ServicePet {
constructor(
public name: string,
public owner: string,
public license: string,
public isRetired: boolean = false
) {}
breathe(): void { console.log("Breathing"); }
play(): void { console.log(`${this.name} is playing.`); }
performDuty(): void { console.log(`${this.name} is guiding.`); }
}
Summary
| Abstract Class | Interface | |
|---|---|---|
| Instantiation | Not allowed | Not allowed (erased after compilation) |
| Contains implementation | Yes (concrete methods) | No (no default methods) |
| Constructor | Yes | No |
| State (properties) | Yes | Yes (declaration only) |
| Multiple inheritance | No (single extends) | Yes (multiple implements) |
| Access modifiers | Yes | All public |
| Use case | Share common implementation, is-a | Define contracts, can-do |
| Exists at runtime | Yes (JS class) | No (type only, erased) |
In the next chapter we cover advanced class features: static members, the Singleton pattern, the Factory pattern, and getters/setters. We will work through class-level state management and the fluent chaining interface pattern with practical examples.