Abstract Classes vs Interfaces: When to Use Which in TypeScript

Master the difference between abstract classes and interfaces in TypeScript. Learn when to use each with practical examples, design patterns, and best practices for clean, maintainable code

πŸ“… Published: May 22, 2025 ✏️ Updated: June 10, 2025 By Ojaswi Athghara
#web-development #abstract #interfaces #oop #design

Abstract Classes vs Interfaces: When to Use Which in TypeScript

When I Chose Wrong (And Copy-Pasted Code Into 50 Files)

Six months into my TypeScript journey, I faced a choice:

// Option 1: Interface
interface Logger {
  log(message: string): void;
}

// Option 2: Abstract Class
abstract class Logger {
  abstract log(message: string): void;
}

"They look the same," I thought. "I'll just use an interface."

Three months later, I needed to add common functionalityβ€”a timestamp method that all loggers should share. With an interface, I had to copy-paste code into every single logger class. 50+ files. Same code. Nightmare.

My mentor looked at my mess and said: "You should've used an abstract class. Let me show you when to use which."

That conversation changed how I design systems. Today, I'll share everything I learned about abstract classes vs interfaces in TypeScript.

What Are Abstract Classes?

Abstract class is a class that:

  • Cannot be instantiated directly
  • Can have both abstract methods (no implementation) and concrete methods (with implementation)
  • Can have properties and constructors
  • Used for inheritance (is-a relationship)
abstract class Animal {
  constructor(public name: string) {}
  
  // Abstract method (must be implemented by child)
  abstract makeSound(): string;
  
  // Concrete method (shared by all children)
  move(distance: number): void {
    console.log(`${this.name} moved ${distance} meters.`);
  }
}

class Dog extends Animal {
  // Must implement abstract method
  makeSound(): string {
    return "Woof!";
  }
}

// Cannot instantiate abstract class
// const animal = new Animal("Generic"); // Error!

// Can instantiate concrete class
const dog = new Dog("Buddy");
console.log(dog.makeSound()); // "Woof!"
dog.move(10);                  // "Buddy moved 10 meters."

What Are Interfaces?

Interface is a contract that:

  • Defines structure (shape) of an object
  • Contains only type declarations (no implementation)
  • Can be implemented by classes or used for type checking
  • Can extend multiple other interfaces
  • Used for contracts (can-do relationship)
interface Logger {
  log(message: string): void;
  logError(error: Error): void;
}

interface Timestamped {
  getTimestamp(): string;
}

// Class can implement multiple interfaces
class ConsoleLogger implements Logger, Timestamped {
  log(message: string): void {
    console.log(`[${this.getTimestamp()}] ${message}`);
  }
  
  logError(error: Error): void {
    console.error(`[${this.getTimestamp()}] ERROR: ${error.message}`);
  }
  
  getTimestamp(): string {
    return new Date().toISOString();
  }
}

const logger = new ConsoleLogger();
logger.log("Application started"); // [2025-05-27T10:30:00.000Z] Application started

Side-by-Side Comparison

// ============================================
// ABSTRACT CLASS
// ============================================
abstract class DatabaseConnection {
  protected connectionString: string;
  
  constructor(connectionString: string) {
    this.connectionString = connectionString;
  }
  
  // Concrete method (shared implementation)
  protected logQuery(query: string): void {
    console.log(`[${new Date().toISOString()}] Executing: ${query}`);
  }
  
  // Abstract methods (must be implemented)
  abstract connect(): Promise<void>;
  abstract query(sql: string): Promise<any[]>;
  abstract disconnect(): Promise<void>;
}

class MySQLConnection extends DatabaseConnection {
  async connect(): Promise<void> {
    this.logQuery(`CONNECT TO ${this.connectionString}`);
    // MySQL-specific connection logic
  }
  
  async query(sql: string): Promise<any[]> {
    this.logQuery(sql);
    // MySQL-specific query logic
    return [];
  }
  
  async disconnect(): Promise<void> {
    this.logQuery("DISCONNECT");
    // MySQL-specific disconnect logic
  }
}

// ============================================
// INTERFACE
// ============================================
interface DatabaseConnection {
  connect(): Promise<void>;
  query(sql: string): Promise<any[]>;
  disconnect(): Promise<void>;
}

class MySQLConnection implements DatabaseConnection {
  constructor(private connectionString: string) {}
  
  // Must implement ALL methods
  async connect(): Promise<void> {
    // No shared logQuery method available!
    console.log(`[${new Date().toISOString()}] CONNECT TO ${this.connectionString}`);
  }
  
  async query(sql: string): Promise<any[]> {
    // Must duplicate logging logic
    console.log(`[${new Date().toISOString()}] Executing: ${sql}`);
    return [];
  }
  
  async disconnect(): Promise<void> {
    // Must duplicate logging logic again
    console.log(`[${new Date().toISOString()}] DISCONNECT`);
  }
}

Key difference: Abstract classes provide shared implementation, interfaces provide a contract only.

When to Use Abstract Classes

Use Case 1: Shared Implementation

When child classes share common logic:

abstract class PaymentProcessor {
  protected transactionId: string;
  
  constructor() {
    this.transactionId = this.generateTransactionId();
  }
  
  // Shared logic (all payments need this)
  private generateTransactionId(): string {
    return `TXN-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
  
  // Shared validation
  protected validateAmount(amount: number): void {
    if (amount <= 0) {
      throw new Error("Amount must be positive");
    }
    if (!Number.isFinite(amount)) {
      throw new Error("Invalid amount");
    }
  }
  
  // Template method pattern
  async processPayment(amount: number): Promise<boolean> {
    this.validateAmount(amount);
    console.log(`Processing payment ${this.transactionId}`);
    
    const result = await this.executePayment(amount);
    
    if (result) {
      this.logSuccess(amount);
    } else {
      this.logFailure(amount);
    }
    
    return result;
  }
  
  // Abstract method (each payment type implements differently)
  protected abstract executePayment(amount: number): Promise<boolean>;
  
  // Shared logging
  private logSuccess(amount: number): void {
    console.log(`βœ… Payment ${this.transactionId} successful: $${amount}`);
  }
  
  private logFailure(amount: number): void {
    console.log(`❌ Payment ${this.transactionId} failed: $${amount}`);
  }
}

class CreditCardProcessor extends PaymentProcessor {
  constructor(private cardNumber: string, private cvv: string) {
    super();
  }
  
  protected async executePayment(amount: number): Promise<boolean> {
    console.log(`Charging credit card ****${this.cardNumber.slice(-4)}`);
    // Credit card specific logic
    return true;
  }
}

class PayPalProcessor extends PaymentProcessor {
  constructor(private email: string) {
    super();
  }
  
  protected async executePayment(amount: number): Promise<boolean> {
    console.log(`Processing PayPal payment for ${this.email}`);
    // PayPal specific logic
    return true;
  }
}

// Usage
const creditCard = new CreditCardProcessor("4532123456789012", "123");
await creditCard.processPayment(99.99);
// Processing payment TXN-1730025000000-abc123xyz
// Charging credit card ****9012
// βœ… Payment TXN-1730025000000-abc123xyz successful: $99.99

const paypal = new PayPalProcessor("user@email.com");
await paypal.processPayment(149.99);
// Processing payment TXN-1730025001000-def456uvw
// Processing PayPal payment for user@email.com
// βœ… Payment TXN-1730025001000-def456uvw successful: $149.99

Why abstract class: All payment processors share transaction ID generation, validation, and logging logic.

Use Case 2: Template Method Pattern

When you want to define an algorithm structure but let subclasses override specific steps:

abstract class DataImporter {
  // Template method (defines algorithm)
  async import(filePath: string): Promise<void> {
    console.log(`\n${"=".repeat(50)}`);
    console.log(`Importing from: ${filePath}`);
    console.log("=".repeat(50));
    
    const data = await this.readFile(filePath);
    const validated = this.validateData(data);
    const transformed = this.transformData(validated);
    await this.saveToDatabase(transformed);
    
    console.log("βœ… Import completed successfully!");
    console.log("=".repeat(50));
  }
  
  // Concrete method (shared)
  protected validateData(data: any[]): any[] {
    console.log(`Validating ${data.length} records...`);
    // Common validation logic
    return data.filter(record => record !== null);
  }
  
  // Abstract methods (subclasses implement)
  protected abstract readFile(filePath: string): Promise<any[]>;
  protected abstract transformData(data: any[]): any[];
  protected abstract saveToDatabase(data: any[]): Promise<void>;
}

class CSVImporter extends DataImporter {
  protected async readFile(filePath: string): Promise<any[]> {
    console.log(`Reading CSV file: ${filePath}`);
    // CSV parsing logic
    return [
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" }
    ];
  }
  
  protected transformData(data: any[]): any[] {
    console.log("Transforming CSV data...");
    // CSV-specific transformation
    return data.map(row => ({ ...row, source: "CSV" }));
  }
  
  protected async saveToDatabase(data: any[]): Promise<void> {
    console.log(`Saving ${data.length} records to database...`);
    // Save logic
  }
}

class JSONImporter extends DataImporter {
  protected async readFile(filePath: string): Promise<any[]> {
    console.log(`Reading JSON file: ${filePath}`);
    // JSON parsing logic
    return [
      { userId: 10, userName: "Charlie" },
      { userId: 20, userName: "David" }
    ];
  }
  
  protected transformData(data: any[]): any[] {
    console.log("Transforming JSON data...");
    // JSON-specific transformation (rename fields)
    return data.map(row => ({
      id: row.userId,
      name: row.userName,
      source: "JSON"
    }));
  }
  
  protected async saveToDatabase(data: any[]): Promise<void> {
    console.log(`Saving ${data.length} records to database...`);
    // Save logic
  }
}

// Usage
const csvImporter = new CSVImporter();
await csvImporter.import("data.csv");

const jsonImporter = new JSONImporter();
await jsonImporter.import("data.json");

Why abstract class: The import algorithm is the same (read β†’ validate β†’ transform β†’ save), but specific steps vary by file type.

Use Case 3: Constructor Logic

When child classes need shared initialization:

abstract class Repository<T> {
  protected tableName: string;
  protected primaryKey: string;
  protected connection: any;
  
  constructor(tableName: string, primaryKey: string = "id") {
    this.tableName = tableName;
    this.primaryKey = primaryKey;
    this.connection = this.establishConnection();
    console.log(`βœ… Repository initialized for table: ${tableName}`);
  }
  
  // Shared initialization
  private establishConnection(): any {
    // Database connection logic
    return { connected: true };
  }
  
  // Shared CRUD operations
  async findById(id: number): Promise<T | null> {
    console.log(`SELECT * FROM ${this.tableName} WHERE ${this.primaryKey} = ${id}`);
    return null; // Simulated
  }
  
  async findAll(): Promise<T[]> {
    console.log(`SELECT * FROM ${this.tableName}`);
    return []; // Simulated
  }
  
  // Abstract methods for custom queries
  abstract findByCustomCriteria(criteria: any): Promise<T[]>;
}

interface User {
  id: number;
  name: string;
  email: string;
}

class UserRepository extends Repository<User> {
  constructor() {
    super("users", "user_id"); // Custom primary key
  }
  
  async findByCustomCriteria(criteria: { email?: string }): Promise<User[]> {
    console.log(`SELECT * FROM ${this.tableName} WHERE email = '${criteria.email}'`);
    return []; // Simulated
  }
  
  // User-specific method
  async findByEmail(email: string): Promise<User | null> {
    return (await this.findByCustomCriteria({ email }))[0] || null;
  }
}

const userRepo = new UserRepository();
// βœ… Repository initialized for table: users

await userRepo.findById(1);
// SELECT * FROM users WHERE user_id = 1

await userRepo.findByEmail("alice@example.com");
// SELECT * FROM users WHERE email = 'alice@example.com'

Why abstract class: All repositories need connection initialization and basic CRUD operations.

When to Use Interfaces

Use Case 1: Multiple Interface Implementation

When a class needs to implement multiple contracts:

interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}

interface Loggable {
  log(): void;
}

interface Comparable<T> {
  compareTo(other: T): number;
}

// Class implements multiple interfaces
class User implements Serializable, Loggable, Comparable<User> {
  constructor(
    public id: number,
    public name: string,
    public email: string
  ) {}
  
  // From Serializable
  serialize(): string {
    return JSON.stringify(this);
  }
  
  deserialize(data: string): void {
    const parsed = JSON.parse(data);
    this.id = parsed.id;
    this.name = parsed.name;
    this.email = parsed.email;
  }
  
  // From Loggable
  log(): void {
    console.log(`User: ${this.name} (${this.email})`);
  }
  
  // From Comparable
  compareTo(other: User): number {
    return this.id - other.id;
  }
}

const user1 = new User(1, "Alice", "alice@example.com");
const user2 = new User(2, "Bob", "bob@example.com");

user1.log();  // User: Alice (alice@example.com)

const serialized = user1.serialize();
console.log(serialized);  // {"id":1,"name":"Alice","email":"alice@example.com"}

console.log(user1.compareTo(user2));  // -1 (user1 < user2)

Why interfaces: TypeScript doesn't support multiple inheritance, but supports multiple interface implementation.

Use Case 2: Type Checking and Contracts

When you need to ensure objects have specific shape:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

function handleResponse<T>(response: ApiResponse<T>): T {
  if (response.status !== 200) {
    throw new Error(`API Error: ${response.message}`);
  }
  console.log(`βœ… Response received at ${response.timestamp}`);
  return response.data;
}

// Type-safe responses
const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Alice", email: "alice@example.com" },
  status: 200,
  message: "Success",
  timestamp: "2025-05-27T10:30:00Z"
};

const productResponse: ApiResponse<Product> = {
  data: { id: 100, title: "Laptop", price: 999.99 },
  status: 200,
  message: "Success",
  timestamp: "2025-05-27T10:31:00Z"
};

const user = handleResponse(userResponse);     // Type: User
const product = handleResponse(productResponse); // Type: Product

Why interface: Pure type contract without implementation.

Use Case 3: Dependency Injection

When you want to depend on abstractions, not implementations:

interface EmailService {
  sendEmail(to: string, subject: string, body: string): Promise<void>;
}

interface Logger {
  log(message: string): void;
  error(message: string): void;
}

// Different implementations
class SendGridEmailService implements EmailService {
  async sendEmail(to: string, subject: string, body: string): Promise<void> {
    console.log(`[SendGrid] Sending email to ${to}`);
  }
}

class AWSEmailService implements EmailService {
  async sendEmail(to: string, subject: string, body: string): Promise<void> {
    console.log(`[AWS SES] Sending email to ${to}`);
  }
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
  
  error(message: string): void {
    console.error(`[ERROR] ${message}`);
  }
}

// Depends on interfaces, not concrete classes
class UserService {
  constructor(
    private emailService: EmailService,  // Interface!
    private logger: Logger               // Interface!
  ) {}
  
  async registerUser(email: string, name: string): Promise<void> {
    this.logger.log(`Registering user: ${name}`);
    
    // Business logic
    
    await this.emailService.sendEmail(
      email,
      "Welcome!",
      `Hello ${name}, welcome to our platform!`
    );
    
    this.logger.log(`User registered successfully: ${name}`);
  }
}

// Easy to swap implementations!
const service1 = new UserService(
  new SendGridEmailService(),
  new ConsoleLogger()
);

const service2 = new UserService(
  new AWSEmailService(),
  new ConsoleLogger()
);

await service1.registerUser("alice@example.com", "Alice");
// [LOG] Registering user: Alice
// [SendGrid] Sending email to alice@example.com
// [LOG] User registered successfully: Alice

await service2.registerUser("bob@example.com", "Bob");
// [LOG] Registering user: Bob
// [AWS SES] Sending email to bob@example.com
// [LOG] User registered successfully: Bob

Why interface: Decouples UserService from specific email and logging implementations.

Key Differences Summary

FeatureAbstract ClassInterface
ImplementationCan have concrete methodsNo implementation
PropertiesCan have propertiesOnly type declarations
ConstructorCan have constructorNo constructor
InheritanceSingle (extends one)Multiple (implements many)
Access ModifiersYes (public, private, protected)No (all public)
When to UseShared implementation + inheritanceContract only + multiple implementations
Use Case"is-a" relationship"can-do" relationship

Can You Use Both Together?

Yes! Combine them for maximum flexibility:

// Interface defines contract
interface Vehicle {
  start(): void;
  stop(): void;
  getInfo(): string;
}

// Abstract class provides partial implementation
abstract class MotorVehicle implements Vehicle {
  constructor(
    protected brand: string,
    protected model: string
  ) {}
  
  // Concrete implementation (shared)
  start(): void {
    console.log(`Starting ${this.brand} ${this.model}...`);
    this.startEngine();
  }
  
  stop(): void {
    console.log(`Stopping ${this.brand} ${this.model}...`);
    this.stopEngine();
  }
  
  // Concrete method (shared)
  getInfo(): string {
    return `${this.brand} ${this.model}`;
  }
  
  // Abstract methods (must implement)
  protected abstract startEngine(): void;
  protected abstract stopEngine(): void;
}

class Car extends MotorVehicle {
  protected startEngine(): void {
    console.log("  πŸš— Turning ignition key...");
  }
  
  protected stopEngine(): void {
    console.log("  πŸš— Turning off ignition...");
  }
}

class Motorcycle extends MotorVehicle {
  protected startEngine(): void {
    console.log("  🏍️ Pressing start button...");
  }
  
  protected stopEngine(): void {
    console.log("  🏍️ Pressing kill switch...");
  }
}

// Function depends on interface
function testVehicle(vehicle: Vehicle): void {
  console.log(`Testing: ${vehicle.getInfo()}`);
  vehicle.start();
  vehicle.stop();
  console.log();
}

const car = new Car("Toyota", "Camry");
const motorcycle = new Motorcycle("Harley", "Sportster");

testVehicle(car);
testVehicle(motorcycle);

Best of both worlds:

  • Interface defines contract (Vehicle)
  • Abstract class provides shared implementation (MotorVehicle)
  • Concrete classes provide specific details (Car, Motorcycle)

Decision Tree: Which Should I Use?

Do you need shared implementation?
β”‚
β”œβ”€ YES β†’ Do you need to implement multiple contracts?
β”‚         β”‚
β”‚         β”œβ”€ YES β†’ Use Interface + Abstract Class combo
β”‚         β”‚
β”‚         └─ NO β†’ Use Abstract Class
β”‚
└─ NO β†’ Do you need multiple contracts?
          β”‚
          β”œβ”€ YES β†’ Use Interfaces
          β”‚
          └─ NO β†’ Use Single Interface

Best Practices

1. Prefer Interfaces for Public APIs

// Good: Public API uses interface
interface DatabaseService {
  query(sql: string): Promise<any[]>;
}

// Implementation detail (can change)
abstract class BaseDatabaseService implements DatabaseService {
  abstract query(sql: string): Promise<any[]>;
}

2. Use Abstract Classes for Framework Code

// Framework provides abstract base
abstract class Component {
  abstract render(): void;
  
  // Shared lifecycle methods
  mount(): void { console.log("Mounting..."); }
  unmount(): void { console.log("Unmounting..."); }
}

// Users extend it
class MyComponent extends Component {
  render(): void {
    console.log("Rendering my component");
  }
}

3. Document Your Choice

/**
 * Abstract class for data repositories.
 * Use this when creating new repository classes.
 * 
 * Provides:
 * - Connection management
 * - Basic CRUD operations
 * - Transaction handling
 * 
 * You must implement:
 * - Custom query methods
 */
abstract class Repository<T> {
  // Implementation
}

Conclusion: Make the Right Choice

Use Abstract Classes when:

  • βœ… You have shared implementation logic
  • βœ… You're using Template Method pattern
  • βœ… You need constructor logic
  • βœ… You have a clear "is-a" relationship

Use Interfaces when:

  • βœ… You need multiple inheritance
  • βœ… You're defining contracts
  • βœ… You want maximum flexibility
  • βœ… You have a "can-do" relationship

Use Both when:

  • βœ… You want shared implementation + contract definition
  • βœ… You're building framework code

Remember: Interfaces define WHAT, abstract classes define WHAT and HOW. Choose based on your needs!


Building robust TypeScript applications? I'd love to hear about your design decisions! Connect with me on Twitter or LinkedIn!

Support My Work

If this guide helped you understand when to use abstract classes vs interfaces in TypeScript, write better type-safe code, or make the right architectural decisions, I'd really appreciate your support! Creating detailed, practical TypeScript tutorials like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for TypeScript developers.

β˜• Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!


Cover image by Sven Mieke on Unsplash

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

Β© ojaswiat.com 2025-2027