TypeScriptAbstract Classes

Abstract Classes

An abstract class defines a common blueprint for a family of related classes. It can contain both concrete (implemented) methods and abstract (unimplemented) methods that every subclass must implement.

You cannot instantiate an abstract class directly — it exists only to be extended. This is the key difference from a regular base class.

Think of an abstract class as a contract plus a partial implementation. Interfaces are pure contracts; regular classes are full implementations; abstract classes sit in between.

Declaring an abstract class

TS
abstract class Shape {
  // Abstract method — subclasses MUST implement this
  abstract area(): number;
  abstract perimeter(): number;

  // Concrete method — shared by all subclasses, no override needed
  describe(): string {
    return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`;
  }

  isLargerThan(other: Shape): boolean {
    return this.area() > other.area();
  }
}

// new Shape(); // Error: Cannot create an instance of 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; }
}

class Rectangle extends Shape {
  constructor(private w: number, private h: number) { super(); }

  area(): number      { return this.w * this.h; }
  perimeter(): number { return 2 * (this.w + this.h); }
}

const c = new Circle(5);
const r = new Rectangle(4, 6);

console.log(c.describe());           // Area: 78.54, Perimeter: 31.42
console.log(r.describe());           // Area: 24.00, Perimeter: 20.00
console.log(c.isLargerThan(r));      // true
Note
The abstract keyword on a method means subclasses must implement it — forgetting to do so is a compile-time error, not a runtime surprise.
Abstract properties

You can also declare abstract properties — forcing each subclass to expose a particular field or getter.

TS
abstract class Vehicle {
  abstract readonly brand: string;   // subclass must provide this
  abstract readonly fuelType: string;

  abstract startEngine(): void;

  // Concrete shared behaviour
  describe(): string {
    return `${this.brand} runs on ${this.fuelType}`;
  }
}

class ElectricCar extends Vehicle {
  readonly brand = 'Tesla';
  readonly fuelType = 'electricity';

  startEngine(): void {
    console.log('Silently started...');
  }
}

class GasCar extends Vehicle {
  constructor(
    readonly brand: string,
    readonly fuelType = 'gasoline',
  ) { super(); }

  startEngine(): void {
    console.log('Vroom!');
  }
}

const cars: Vehicle[] = [new ElectricCar(), new GasCar('Toyota')];
cars.forEach(car => {
  console.log(car.describe());
  car.startEngine();
});
Abstract classes vs interfaces

Both abstract classes and interfaces define contracts. The choice between them is a design decision:

Feature

Interface

Abstract class

Can contain implementation

Can have constructors

Multiple inheritance

✅ (a class can implement many)

❌ (single extends)

Can have private/protected members

Runtime presence (emitted JS)

❌ (erased)

✅ (real class)

Use when…

defining a pure shape/contract

sharing code + enforcing contract

Tip
The rule of thumb: use an interface when you only need a contract. Use an abstract class when you need to share behaviour (concrete methods or constructor logic) alongside the contract.
Constructor parameters in abstract classes

Abstract classes can have constructors with parameters. Subclasses must call super() to initialize the base.

TS
abstract class Repository<T extends { id: number }> {
  protected items: T[] = [];

  constructor(protected readonly name: string) {
    console.log(`Created repository: ${name}`);
  }

  findById(id: number): T | undefined {
    return this.items.find(item => item.id === id);
  }

  findAll(): T[] {
    return [...this.items]; // return a copy
  }

  abstract save(item: T): void;
  abstract delete(id: number): boolean;
}

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

class UserRepository extends Repository<User> {
  constructor() { super('users'); }

  save(user: User): void {
    const index = this.items.findIndex(u => u.id === user.id);
    if (index >= 0) {
      this.items[index] = user; // update
    } else {
      this.items.push(user);    // insert
    }
  }

  delete(id: number): boolean {
    const before = this.items.length;
    this.items = this.items.filter(u => u.id !== id);
    return this.items.length < before;
  }

  findByEmail(email: string): User | undefined {
    return this.items.find(u => u.email === email);
  }
}

const repo = new UserRepository();
repo.save({ id: 1, name: 'Alice', email: 'alice@example.com' });
repo.save({ id: 2, name: 'Bob',   email: 'bob@example.com' });

console.log(repo.findById(1)?.name); // Alice
console.log(repo.findAll().length);  // 2
repo.delete(1);
console.log(repo.findAll().length);  // 1
Template Method pattern

The classic use of abstract classes is the Template Method design pattern: the abstract class defines the skeleton of an algorithm and lets subclasses fill in the steps.

TS
abstract class DataProcessor {
  // Template method — defines the algorithm skeleton
  process(rawData: string): void {
    const parsed  = this.parse(rawData);
    const cleaned = this.clean(parsed);
    const result  = this.transform(cleaned);
    this.output(result);
  }

  // Steps that subclasses must implement
  protected abstract parse(raw: string): unknown[];
  protected abstract transform(data: unknown[]): unknown[];

  // Steps with default implementations that subclasses can override
  protected clean(data: unknown[]): unknown[] {
    return data.filter(item => item !== null && item !== undefined);
  }

  protected output(result: unknown[]): void {
    console.log(JSON.stringify(result, null, 2));
  }
}

class CsvProcessor extends DataProcessor {
  protected parse(raw: string): string[][] {
    return raw.trim().split('\n').map(line => line.split(','));
  }

  protected transform(data: string[][]): Record<string, string>[] {
    const [headers, ...rows] = data;
    return rows.map(row =>
      Object.fromEntries(headers.map((h, i) => [h.trim(), row[i]?.trim() ?? '']))
    );
  }
}

const csv = 'name,age\nAlice,30\nBob,25';
new CsvProcessor().process(csv);
// [{ "name": "Alice", "age": "30" }, { "name": "Bob", "age": "25" }]
Protected abstract methods

Abstract methods can be protected, meaning they are implementation details of the class hierarchy — not part of the public API.

TS
abstract class Logger {
  log(message: string): void {
    const formatted = this.format(message);
    this.write(formatted);
  }

  protected abstract format(message: string): string;
  protected abstract write(message: string): void;
}

class ConsoleLogger extends Logger {
  protected format(msg: string): string {
    return `[${new Date().toISOString()}] ${msg}`;
  }
  protected write(msg: string): void {
    console.log(msg);
  }
}

class FileLogger extends Logger {
  constructor(private path: string) { super(); }

  protected format(msg: string): string {
    return `${Date.now()}||${msg}\n`;
  }
  protected write(msg: string): void {
    // In real code: fs.appendFileSync(this.path, msg)
    console.log(`Writing to ${this.path}: ${msg}`);
  }
}

const logger: Logger = new ConsoleLogger();
logger.log('Application started');
Using abstract classes as type annotations

Abstract classes work as types. A variable typed as an abstract class accepts any concrete subclass instance.

TS
abstract class Renderer {
  abstract render(content: string): string;
}

class HtmlRenderer extends Renderer {
  render(content: string): string {
    return `<div>${content}</div>`;
  }
}

class MarkdownRenderer extends Renderer {
  render(content: string): string {
    return content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
  }
}

function renderPage(renderer: Renderer, content: string): void {
  console.log(renderer.render(content));
}

renderPage(new HtmlRenderer(),     'Hello world');     // <div>Hello world</div>
renderPage(new MarkdownRenderer(), '**Bold** text');   // <strong>Bold</strong> text
Common mistakes
  1. Trying to instantiate an abstract class directly with new — always a compile error.

  2. Forgetting to implement all abstract methods in a subclass — TypeScript catches this at compile time.

  3. Overusing abstract classes when a plain interface would suffice. If you have no shared implementation, use an interface.

  4. Not calling super() in the subclass constructor when the abstract class constructor has required parameters.

Success
Abstract classes are the TypeScript way to implement the Template Method pattern and other inheritance-based designs. They enforce contracts at compile time while sharing reusable logic across a class hierarchy.