Abstract Classes and Methods


Understanding Abstract Classes
Abstract classes are blueprints for other classes. They define a structure that subclasses must follow while providing some shared functionality. Think of them as incomplete classes—they have some implemented methods and some methods that must be filled in by subclasses.
You mark a class as abstract with the abstract keyword. Abstract classes cannot be instantiated directly—you can only create instances of concrete classes that extend them.
abstract class Animal {
constructor(public name: string) {}
// Concrete method - all animals have this
move(distance: number): void {
console.log(`${this.name} moved ${distance} meters`);
}
// Abstract method - each animal implements differently
abstract makeSound(): void;
}
class Dog extends Animal {
makeSound(): void {
console.log(`${this.name} barks: Woof!`);
}
}
class Cat extends Animal {
makeSound(): void {
console.log(`${this.name} meows: Meow!`);
}
}
const dog = new Dog("Buddy");
dog.move(10); // "Buddy moved 10 meters"
dog.makeSound(); // "Buddy barks: Woof!"
const cat = new Cat("Whiskers");
cat.makeSound(); // "Whiskers meows: Meow!"
// const animal = new Animal("Generic"); // Error: Cannot create instance of abstract classAnimal provides move() for all animals but requires each subclass to define makeSound(). You can't create a generic Animal—only specific types like Dog or Cat.
Abstract Classes in Action
Abstract classes let you share code between related classes while enforcing that certain methods must be implemented. They're perfect for inheritance hierarchies where you have common functionality but need subclass-specific behavior.
Abstract Methods
Abstract methods are method signatures without implementations. They define the shape of a method that subclasses must provide.
Basic Abstract Methods
abstract class Shape {
constructor(public color: string) {}
// Abstract - subclasses must implement
abstract getArea(): number;
abstract getPerimeter(): number;
// Concrete - all shapes share this
describe(): string {
return `A ${this.color} shape with area ${this.getArea().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(
color: string,
public radius: number
) {
super(color);
}
getArea(): number {
return Math.PI * this.radius * this.radius;
}
getPerimeter(): number {
return 2 * Math.PI * this.radius;
}
}
class Rectangle extends Shape {
constructor(
color: string,
public width: number,
public height: number
) {
super(color);
}
getArea(): number {
return this.width * this.height;
}
getPerimeter(): number {
return 2 * (this.width + this.height);
}
}
const circle = new Circle("red", 5);
console.log(circle.describe()); // "A red shape with area 78.54"
console.log(circle.getPerimeter()); // 31.42
const rectangle = new Rectangle("blue", 4, 6);
console.log(rectangle.describe()); // "A blue shape with area 24.00"
console.log(rectangle.getPerimeter()); // 20Each shape knows how to calculate its own area and perimeter, but they all share the describe() method from the base class.
Abstract Methods with Parameters
abstract class Logger {
abstract log(message: string, level: "info" | "warn" | "error"): void;
abstract clear(): void;
// Concrete helper methods
info(message: string): void {
this.log(message, "info");
}
warn(message: string): void {
this.log(message, "warn");
}
error(message: string): void {
this.log(message, "error");
}
}
class ConsoleLogger extends Logger {
log(message: string, level: "info" | "warn" | "error"): void {
const timestamp = new Date().toISOString();
const formatted = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
switch (level) {
case "error":
console.error(formatted);
break;
case "warn":
console.warn(formatted);
break;
default:
console.log(formatted);
}
}
clear(): void {
console.clear();
}
}
class FileLogger extends Logger {
private logs: string[] = [];
log(message: string, level: "info" | "warn" | "error"): void {
const timestamp = new Date().toISOString();
this.logs.push(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
}
clear(): void {
this.logs = [];
}
getLogs(): string[] {
return [...this.logs];
}
}
const consoleLogger = new ConsoleLogger();
consoleLogger.info("Application started");
consoleLogger.error("Something went wrong");
const fileLogger = new FileLogger();
fileLogger.info("User logged in");
fileLogger.warn("Low memory");
console.log(fileLogger.getLogs());Both loggers implement the same abstract methods but with different behaviors. The convenience methods (info(), warn(), error()) work the same for all loggers.
Complete Implementation Required
When you extend an abstract class, you MUST implement all abstract methods. If you miss even one, TypeScript throws a compile error. This ensures subclasses fulfill the contract defined by the base class.
Partial Implementation
Abstract classes can mix concrete implementations with abstract requirements. This is their superpower—they provide shared functionality while enforcing that specific methods are implemented.
Shared State and Behavior
abstract class DataStore<T> {
protected items: Map<string, T> = new Map();
// Abstract - each store implements differently
abstract save(id: string, item: T): Promise<void>;
abstract load(id: string): Promise<T | null>;
abstract delete(id: string): Promise<boolean>;
// Concrete - all stores share this logic
has(id: string): boolean {
return this.items.has(id);
}
count(): number {
return this.items.size;
}
clear(): void {
this.items.clear();
}
getAll(): T[] {
return Array.from(this.items.values());
}
}
class MemoryStore<T> extends DataStore<T> {
async save(id: string, item: T): Promise<void> {
this.items.set(id, item);
}
async load(id: string): Promise<T | null> {
return this.items.get(id) || null;
}
async delete(id: string): Promise<boolean> {
return this.items.delete(id);
}
}
class LocalStorageStore<T> extends DataStore<T> {
private prefix: string;
constructor(prefix: string = "app") {
super();
this.prefix = prefix;
}
async save(id: string, item: T): Promise<void> {
const key = `${this.prefix}_${id}`;
localStorage.setItem(key, JSON.stringify(item));
this.items.set(id, item);
}
async load(id: string): Promise<T | null> {
const key = `${this.prefix}_${id}`;
const data = localStorage.getItem(key);
if (data) {
const item = JSON.parse(data) as T;
this.items.set(id, item);
return item;
}
return null;
}
async delete(id: string): Promise<boolean> {
const key = `${this.prefix}_${id}`;
localStorage.removeItem(key);
return this.items.delete(id);
}
}
// Both stores share has(), count(), clear(), getAll()
const memStore = new MemoryStore<{ name: string }>();
await memStore.save("1", { name: "Alice" });
console.log(memStore.count()); // 1
console.log(memStore.has("1")); // true
const lsStore = new LocalStorageStore<{ name: string }>("users");
await lsStore.save("2", { name: "Bob" });
console.log(lsStore.getAll()); // [{ name: "Bob" }]The abstract class provides the shared items map and utility methods. Subclasses only implement the storage-specific operations.
Protected Members in Abstract Classes
abstract class HttpClient {
protected baseUrl: string;
protected headers: Record<string, string> = {};
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/$/, ""); // Remove trailing slash
}
// Concrete - shared across all clients
setHeader(key: string, value: string): void {
this.headers[key] = value;
}
protected buildUrl(path: string): string {
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${this.baseUrl}${cleanPath}`;
}
// Abstract - each client implements differently
abstract get<T>(path: string): Promise<T>;
abstract post<T>(path: string, data: unknown): Promise<T>;
abstract delete(path: string): Promise<void>;
}
class FetchClient extends HttpClient {
async get<T>(path: string): Promise<T> {
const response = await fetch(this.buildUrl(path), {
headers: this.headers,
});
return response.json();
}
async post<T>(path: string, data: unknown): Promise<T> {
const response = await fetch(this.buildUrl(path), {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return response.json();
}
async delete(path: string): Promise<void> {
await fetch(this.buildUrl(path), {
method: "DELETE",
headers: this.headers,
});
}
}
const client = new FetchClient("https://api.example.com");
client.setHeader("Authorization", "Bearer token123");
const data = await client.get("/users/1");Protected members (baseUrl, headers, buildUrl()) are accessible to subclasses but hidden from external code.
abstract class DataStoreCannot instantiateabstract save()abstract load()abstract delete()has()count()clear()getAll()class MemoryStoreclass LocalStorageStoreclass IndexedDBStoreAbstract vs Interface
Both abstract classes and interfaces define contracts, but they serve different purposes.
Key Differences
Abstract Classes:
- Can have implementation (concrete methods)
- Can have state (properties with values)
- Support single inheritance only
- Can have constructors
- Can have access modifiers (private, protected)
- Use when you have shared code
Interfaces:
- No implementation at all
- No state (only property shapes)
- Support multiple inheritance
- Cannot have constructors
- All members implicitly public
- Use when you only need a contract
// Interface - pure contract
interface Flyable {
fly(): void;
altitude: number;
}
// Abstract class - contract + implementation
abstract class Bird {
constructor(public species: string) {}
// Concrete
eat(): void {
console.log(`${this.species} is eating`);
}
// Abstract
abstract makeSound(): void;
}
// Can implement interface AND extend abstract class
class Eagle extends Bird implements Flyable {
altitude: number = 0;
fly(): void {
this.altitude += 100;
console.log(`${this.species} flying at ${this.altitude}m`);
}
makeSound(): void {
console.log(`${this.species} screeches`);
}
}
const eagle = new Eagle("Golden Eagle");
eagle.eat(); // From abstract class
eagle.fly(); // From interface
eagle.makeSound(); // Required by abstract classWhen to Use Each
Use Abstract Classes when:
// ✅ You have shared implementation
abstract class Vehicle {
constructor(public model: string) {}
// Shared code
start(): void {
console.log(`${this.model} starting...`);
}
// Each vehicle accelerates differently
abstract accelerate(): void;
}Use Interfaces when:
// ✅ You only need a contract, no shared code
interface Serializable {
toJSON(): string;
fromJSON(json: string): void;
}
// ✅ You need multiple inheritance
class User implements Serializable, Comparable, Cloneable {
// Can implement multiple interfaces
}Use Both when:
// ✅ Interface for contract, abstract class for shared code
interface Drawable {
draw(): void;
}
abstract class UIComponent implements Drawable {
protected x: number = 0;
protected y: number = 0;
// Shared positioning
moveTo(x: number, y: number): void {
this.x = x;
this.y = y;
}
// Must be implemented
abstract draw(): void;
}
class Button extends UIComponent {
draw(): void {
console.log(`Drawing button at (${this.x}, ${this.y})`);
}
}Choosing Between Abstract and Interface
Default to interfaces for contracts. Use abstract classes when you need to share implementation or state. If you need both a contract AND shared code, use an interface with an abstract class that implements it.
The Template Method Pattern
The template method pattern defines the skeleton of an algorithm in a base class, letting subclasses override specific steps. Abstract classes are perfect for this.
Basic Template Method
abstract class DataProcessor {
// Template method - defines the algorithm structure
process(data: string[]): string[] {
const validated = this.validate(data);
const transformed = this.transform(validated);
const filtered = this.filter(transformed);
const sorted = this.sort(filtered);
return sorted;
}
// Steps - some concrete, some abstract
protected validate(data: string[]): string[] {
// Shared validation
return data.filter((item) => item.trim().length > 0);
}
protected abstract transform(data: string[]): string[];
protected abstract filter(data: string[]): string[];
protected sort(data: string[]): string[] {
// Default sorting
return [...data].sort();
}
}
class EmailProcessor extends DataProcessor {
protected transform(data: string[]): string[] {
// Normalize emails
return data.map((email) => email.toLowerCase().trim());
}
protected filter(data: string[]): string[] {
// Only valid email format
return data.filter((email) => email.includes("@"));
}
}
class UsernameProcessor extends DataProcessor {
protected transform(data: string[]): string[] {
// Remove special characters
return data.map((name) => name.replace(/[^a-zA-Z0-9]/g, ""));
}
protected filter(data: string[]): string[] {
// Only alphanumeric, 3-20 chars
return data.filter((name) => name.length >= 3 && name.length <= 20);
}
}
const emails = [" [email protected] ", "invalid", "[email protected]"];
const emailProc = new EmailProcessor();
console.log(emailProc.process(emails));
// ["[email protected]", "[email protected]"]
const usernames = ["Alice!", " bob_123 ", "x"];
const userProc = new UsernameProcessor();
console.log(userProc.process(usernames));
// ["Alice", "bob123"]The process() method defines the workflow. Subclasses customize individual steps without changing the overall algorithm.
Advanced Template Method
abstract class ReportGenerator {
// Template method
generate(): string {
const header = this.renderHeader();
const body = this.renderBody();
const footer = this.renderFooter();
return this.assemble(header, body, footer);
}
protected abstract renderHeader(): string;
protected abstract renderBody(): string;
// Default footer - can be overridden
protected renderFooter(): string {
return `Generated on ${new Date().toLocaleDateString()}`;
}
// Concrete assembly logic
protected assemble(header: string, body: string, footer: string): string {
return `${header}\n\n${body}\n\n${footer}`;
}
}
class HTMLReport extends ReportGenerator {
constructor(private data: { title: string; content: string[] }) {
super();
}
protected renderHeader(): string {
return `<h1>${this.data.title}</h1>`;
}
protected renderBody(): string {
return `<ul>\n${this.data.content.map((item) => ` <li>${item}</li>`).join("\n")}\n</ul>`;
}
protected renderFooter(): string {
return `<footer><em>${super.renderFooter()}</em></footer>`;
}
}
class MarkdownReport extends ReportGenerator {
constructor(private data: { title: string; content: string[] }) {
super();
}
protected renderHeader(): string {
return `# ${this.data.title}`;
}
protected renderBody(): string {
return this.data.content.map((item) => `- ${item}`).join("\n");
}
}
const data = {
title: "Q4 Results",
content: ["Revenue: \$1M", "Growth: 25\%", "Customers: 500"],
};
const htmlReport = new HTMLReport(data);
console.log(htmlReport.generate());
const mdReport = new MarkdownReport(data);
console.log(mdReport.generate());Real-World Examples
Payment Processing
abstract class PaymentProcessor {
// Template method
async processPayment(amount: number, currency: string): Promise<boolean> {
if (!this.validateAmount(amount)) {
throw new Error("Invalid amount");
}
const converted = await this.convertCurrency(amount, currency);
const authorized = await this.authorize(converted);
if (!authorized) {
return false;
}
await this.capture(converted);
await this.sendReceipt(converted);
return true;
}
protected validateAmount(amount: number): boolean {
return amount > 0 && amount < 1000000;
}
protected abstract convertCurrency(
amount: number,
currency: string
): Promise<number>;
protected abstract authorize(amount: number): Promise<boolean>;
protected abstract capture(amount: number): Promise<void>;
protected async sendReceipt(amount: number): Promise<void> {
console.log(`Receipt sent for \$${amount.toFixed(2)}`);
}
}
class StripeProcessor extends PaymentProcessor {
protected async convertCurrency(
amount: number,
currency: string
): Promise<number> {
// Call Stripe API for conversion
return amount; // Simplified
}
protected async authorize(amount: number): Promise<boolean> {
// Stripe authorization
return amount < 10000; // Simplified
}
protected async capture(amount: number): Promise<void> {
console.log(`Stripe captured \$${amount}`);
}
}
class PayPalProcessor extends PaymentProcessor {
protected async convertCurrency(
amount: number,
currency: string
): Promise<number> {
// PayPal conversion
return amount;
}
protected async authorize(amount: number): Promise<boolean> {
// PayPal authorization
return true;
}
protected async capture(amount: number): Promise<void> {
console.log(`PayPal captured \$${amount}`);
}
}Database Connections
abstract class DatabaseConnection {
protected connected: boolean = false;
async execute(query: string): Promise<void> {
if (!this.connected) {
await this.connect();
}
await this.runQuery(query);
}
protected async connect(): Promise<void> {
console.log("Establishing connection...");
await this.authenticate();
await this.selectDatabase();
this.connected = true;
console.log("Connected");
}
async disconnect(): Promise<void> {
if (this.connected) {
await this.closeConnection();
this.connected = false;
console.log("Disconnected");
}
}
protected abstract authenticate(): Promise<void>;
protected abstract selectDatabase(): Promise<void>;
protected abstract runQuery(query: string): Promise<void>;
protected abstract closeConnection(): Promise<void>;
}
class PostgresConnection extends DatabaseConnection {
protected async authenticate(): Promise<void> {
console.log("Postgres: Authenticating...");
}
protected async selectDatabase(): Promise<void> {
console.log("Postgres: Selecting database...");
}
protected async runQuery(query: string): Promise<void> {
console.log(`Postgres: ${query}`);
}
protected async closeConnection(): Promise<void> {
console.log("Postgres: Closing connection");
}
}
class MongoConnection extends DatabaseConnection {
protected async authenticate(): Promise<void> {
console.log("MongoDB: Authenticating...");
}
protected async selectDatabase(): Promise<void> {
console.log("MongoDB: Selecting database...");
}
protected async runQuery(query: string): Promise<void> {
console.log(`MongoDB: ${query}`);
}
protected async closeConnection(): Promise<void> {
console.log("MongoDB: Closing connection");
}
}Common Patterns
Factory with Abstract Products
abstract class Document {
constructor(public title: string) {}
abstract render(): string;
abstract save(path: string): void;
getMetadata(): { title: string; created: Date } {
return {
title: this.title,
created: new Date(),
};
}
}
class PDFDocument extends Document {
render(): string {
return `PDF: ${this.title}`;
}
save(path: string): void {
console.log(`Saving PDF to ${path}`);
}
}
class WordDocument extends Document {
render(): string {
return `DOCX: ${this.title}`;
}
save(path: string): void {
console.log(`Saving Word doc to ${path}`);
}
}
class DocumentFactory {
static create(type: "pdf" | "word", title: string): Document {
switch (type) {
case "pdf":
return new PDFDocument(title);
case "word":
return new WordDocument(title);
}
}
}
const doc = DocumentFactory.create("pdf", "Report");
console.log(doc.render());
doc.save("/documents/report.pdf");Command Pattern
abstract class Command {
abstract execute(): void;
abstract undo(): void;
protected log(message: string): void {
console.log(`[${this.constructor.name}] ${message}`);
}
}
class AddCommand extends Command {
constructor(
private list: string[],
private item: string
) {
super();
}
execute(): void {
this.list.push(this.item);
this.log(`Added "${this.item}"`);
}
undo(): void {
const removed = this.list.pop();
this.log(`Removed "${removed}"`);
}
}
class RemoveCommand extends Command {
private removedItem?: string;
private removedIndex?: number;
constructor(
private list: string[],
private index: number
) {
super();
}
execute(): void {
this.removedItem = this.list[this.index];
this.removedIndex = this.index;
this.list.splice(this.index, 1);
this.log(`Removed "${this.removedItem}" at index ${this.index}`);
}
undo(): void {
if (this.removedItem !== undefined && this.removedIndex !== undefined) {
this.list.splice(this.removedIndex, 0, this.removedItem);
this.log(`Restored "${this.removedItem}" at index ${this.removedIndex}`);
}
}
}
const list: string[] = ["A", "B", "C"];
const addCmd = new AddCommand(list, "D");
const removeCmd = new RemoveCommand(list, 1);
addCmd.execute(); // ["A", "B", "C", "D"]
removeCmd.execute(); // ["A", "C", "D"]
removeCmd.undo(); // ["A", "B", "C", "D"]
addCmd.undo(); // ["A", "B", "C"]Best Practices and Guidelines
Design Guidelines
✅ Keep abstract classes focused
// ✅ Good: Single responsibility
abstract class Validator {
abstract validate(value: unknown): boolean;
}
// ❌ Bad: Too many responsibilities
abstract class SuperClass {
abstract validate(): boolean;
abstract save(): void;
abstract render(): string;
abstract sendEmail(): void;
}✅ Use meaningful names
// ✅ Good: Clear purpose
abstract class DataTransformer {}
abstract class ReportBuilder {}
abstract class PaymentGateway {}
// ❌ Bad: Vague names
abstract class Base {}
abstract class Helper {}
abstract class Manager {}✅ Provide reasonable defaults
abstract class Formatter {
// Concrete with sensible default
protected indent: number = 2;
setIndent(spaces: number): void {
this.indent = spaces;
}
abstract format(data: unknown): string;
}Implementation Guidelines
✅ Make abstract methods protected when appropriate
abstract class Algorithm {
// Public template method
run(): void {
this.step1();
this.step2();
this.step3();
}
// Protected steps - not part of public API
protected abstract step1(): void;
protected abstract step2(): void;
protected abstract step3(): void;
}✅ Document what subclasses must do
abstract class Plugin {
/**
* Initialize the plugin.
* Subclasses should set up resources and validate configuration.
* @throws Error if initialization fails
*/
abstract initialize(): Promise<void>;
/**
* Clean up plugin resources.
* Called when plugin is being disabled.
*/
abstract cleanup(): Promise<void>;
}Common Mistake
Don't make abstract classes too large. If you find yourself with 10+ abstract methods, you probably need to split the class or use composition instead of inheritance. Large abstract classes are hard to implement and maintain.
What's Next
You now understand abstract classes and methods. In the next posts, you'll learn:
- Class inheritance and the
superkeyword - Implementing interfaces in classes
- Parameter properties shorthand
- Mixins for multiple inheritance patterns
Key Takeaways
Abstract classes give you:
✅ Partial implementation - share code while enforcing contracts ✅ Template methods - define algorithms with customizable steps ✅ Protected members - share internals with subclasses ✅ Type safety - compiler enforces implementation of abstract methods ✅ Code reuse - avoid duplicating common functionality ✅ Clear contracts - document what subclasses must provide
Use abstract classes when you have shared implementation to reuse. Use interfaces when you only need a contract. Use both together when you need the benefits of each. Keep abstract classes focused and well-documented, and avoid making them too large or complex.