TypeScript Abstract Classes

An abstract class is a class that can never be instantiated directly — it exists only to be extended. It sits between a plain class (fully implemented, instantiable) and an interface (pure shape, no implementation, erased at compile time). Abstract classes let you write shared logic once while forcing every subclass to supply the pieces that only make sense for that subclass, such as calculateArea() for a specific shape or calculatePay() for a specific job title.

They are one of TypeScript’s most useful tools for modeling “this is a family of related things that all share behavior, but each member is different in a specific, mandatory way.”

Overview: How Abstract Classes Work

You declare an abstract class with the abstract keyword in front of class. Inside it, you can mix two kinds of members:

  • Concrete members — regular methods, properties, and constructors with full implementations, inherited as-is by every subclass.
  • Abstract members — methods or properties marked with abstract that declare only a signature, with no body. Every non-abstract subclass must provide an implementation.

The TypeScript compiler enforces two rules at compile time:

  • You cannot write new SomeAbstractClass() anywhere in your code — the compiler rejects it.
  • Any class that extends an abstract class, and is not itself declared abstract, must implement every abstract member it inherits, or the compiler reports an error.

This differs from an interface in an important way: an abstract class can carry real, shared implementation (fields with default values, methods with bodies, a constructor that runs setup logic), while an interface can only describe shape. Abstract classes also produce a real runtime construct (a JavaScript class), whereas interfaces vanish completely once compiled.

Syntax

abstract class Base {
  abstract requiredMethod(param: string): number;
  abstract requiredProperty: boolean;

  concreteMethod(): void {
    console.log("shared logic every subclass inherits");
  }
}
  • abstract class Base — the abstract keyword before class marks the whole class as non-instantiable.
  • abstract requiredMethod(param: string): number; — an abstract method: a signature only, ended with a semicolon, no { } body. Subclasses must implement it.
  • abstract requiredProperty: boolean; — an abstract property works the same way; it declares a type that subclasses must satisfy (usually by assigning a value or via a getter).
  • concreteMethod(): void { ... } — a normal method with a body. It is inherited by every subclass exactly like in a regular base class.

Examples

Example 1: A shape hierarchy

abstract class Shape {
  abstract getArea(): number;

  describe(): string {
    return `This shape has an area of ${this.getArea().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }
  getArea(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Rectangle extends Shape {
  constructor(private width: number, private height: number) {
    super();
  }
  getArea(): number {
    return this.width * this.height;
  }
}

const shapes: Shape[] = [new Circle(3), new Rectangle(4, 5)];
for (const shape of shapes) {
  console.log(shape.describe());
}

Output:

This shape has an area of 28.27
This shape has an area of 20.00

Shape declares getArea() as abstract because there is no single sensible default — a circle and a rectangle compute area completely differently. But describe() is concrete: it is identical for every shape, so it lives once in the base class and calls the abstract method polymorphically. Note that shapes is typed as Shape[], not as a union of the concrete classes — this is normal, idiomatic use of an abstract class as a common type.

Example 2: Shared setup logic with a protected constructor

abstract class Employee {
  protected constructor(public name: string, protected baseSalary: number) {}

  abstract calculateBonus(): number;

  getTotalPay(): number {
    return this.baseSalary + this.calculateBonus();
  }
}

class Manager extends Employee {
  constructor(name: string, baseSalary: number, private teamSize: number) {
    super(name, baseSalary);
  }
  calculateBonus(): number {
    return this.teamSize * 500;
  }
}

class Developer extends Employee {
  constructor(name: string, baseSalary: number, private linesShipped: number) {
    super(name, baseSalary);
  }
  calculateBonus(): number {
    return Math.min(this.linesShipped * 0.1, 2000);
  }
}

const staff: Employee[] = [
  new Manager("Ava", 70000, 6),
  new Developer("Ben", 65000, 12000),
];

for (const person of staff) {
  console.log(`${person.name}: $${person.getTotalPay()}`);
}

Output:

Ava: $73000
Ben: $66200

Here the constructor is marked protected. That means code outside the class hierarchy cannot call new Employee(...) even indirectly through construction tricks — but subclasses can still call super(name, baseSalary) from their own constructors. getTotalPay() is a template method: it is written once against the abstract calculateBonus(), and each subclass supplies its own bonus formula.

Example 3: Abstract properties

abstract class Animal {
  abstract readonly sound: string;

  constructor(public name: string) {}

  makeSound(): void {
    console.log(`${this.name} says ${this.sound}`);
  }
}

class Dog extends Animal {
  readonly sound = "Woof";
}

class Cat extends Animal {
  readonly sound = "Meow";
}

const animals: Animal[] = [new Dog("Rex"), new Cat("Whiskers")];
animals.forEach(a => a.makeSound());

Output:

Rex says Woof
Whiskers says Meow

Abstract members are not limited to methods — abstract readonly sound: string; declares a property that every subclass must define. Dog and Cat never write their own constructor, so they automatically inherit Animal‘s constructor; they only need to satisfy the abstract property requirement.

Under the Hood

When TypeScript compiles an abstract class, the abstract keyword itself and any abstract member declarations (which have no body) are stripped out — there is nothing to emit for a signature with no implementation. What remains is compiled to an ordinary JavaScript class. This is a key difference from interfaces: an interface leaves zero trace in the compiled output, while an abstract class still exists at runtime as a real class, because it can hold actual shared implementation.

This has a practical consequence: the “cannot instantiate an abstract class” rule is a compile-time-only check. Once compiled to JavaScript, there is no special runtime guard — if you were to bypass the type checker (for example by importing the compiled .js output directly, or by using a type assertion trick), nothing at runtime stops you from calling the class’s constructor. The safety abstract classes provide is a design-time guarantee enforced by tsc, not a runtime one.

Similarly, an abstract method exists in the compiled base class only as whatever the subclass defines — the abstract declaration in the parent contributes no runtime code at all. The type checker uses the declaration purely to verify, member by member, that every concrete subclass provides a compatible implementation before it lets your program compile.

Common Mistakes

Mistake 1: Trying to instantiate the abstract class directly

abstract class Vehicle {
  abstract startEngine(): void;
}

const v = new Vehicle();
// Error: Cannot create an instance of an abstract class.

TypeScript refuses to compile this because Vehicle is declared abstract — it can only be used as a base class. The fix is to always instantiate a concrete subclass instead:

abstract class Vehicle {
  abstract startEngine(): void;
}

class Car extends Vehicle {
  startEngine(): void {
    console.log("Vroom!");
  }
}

const myCar = new Car();
myCar.startEngine();

Output:

Vroom!

Mistake 2: Forgetting to implement an abstract member

abstract class Vehicle {
  abstract startEngine(): void;
}

class Truck extends Vehicle {
}
// Error: Non-abstract class 'Truck' does not implement
// inherited abstract member 'startEngine' from class 'Vehicle'.

Because Truck is a concrete (non-abstract) class, it is required to implement every abstract member from Vehicle. Leaving startEngine unimplemented is a compile error, not a runtime surprise — TypeScript catches the missing piece before the code ever runs:

abstract class Vehicle {
  abstract startEngine(): void;
}

class Truck extends Vehicle {
  startEngine(): void {
    console.log("Diesel engine roaring to life");
  }
}

const myTruck = new Truck();
myTruck.startEngine();

Output:

Diesel engine roaring to life

Best Practices

  • Reach for an abstract class when subclasses share real implementation (fields, helper methods, a constructor) in addition to a common shape — if there’s no shared implementation at all, prefer a plain interface.
  • Keep abstract methods focused on the one piece of behavior that genuinely varies between subclasses; put everything else as concrete methods on the base class.
  • Use a protected constructor when you want to guarantee, at the type level, that only subclasses can construct instances.
  • Favor the “template method” pattern: write a concrete method on the base class that calls one or more abstract methods, so shared control flow lives in exactly one place.
  • Don’t mark a class abstract just to prevent instantiation for unrelated reasons — abstract signals “this type is incomplete without a subclass,” not “please don’t construct this.”
  • Abstract classes can implement interfaces too; use that combination when you need both a contract (interface) and shared base logic (abstract class).

Practice Exercises

  • Create an abstract class PaymentMethod with an abstract method processPayment(amount: number): string and a concrete method logTransaction(amount: number): void that logs a message using the result of processPayment. Implement two subclasses, CreditCardPayment and PayPalPayment, each with its own processPayment logic.
  • Write an abstract class Notification with an abstract readonly property channel: string and a concrete method send(message: string): void that prints `[${this.channel}] ${message}`. Create EmailNotification and SmsNotification subclasses and send a message through each.
  • Given an abstract class that fails to compile because a subclass is missing an abstract member, identify exactly which member is missing and add the implementation so the code compiles under --strict.

Summary

  • An abstract class cannot be instantiated directly — it can only be extended.
  • Abstract members (abstract methodName(): ReturnType or abstract propName: Type) declare a required signature with no body; concrete members provide full, shared implementation.
  • Every non-abstract subclass must implement all inherited abstract members, or tsc reports a compile error.
  • Unlike interfaces, abstract classes exist at runtime as real JavaScript classes, since they can carry actual implementation — only the abstract keyword and empty signatures are erased.
  • Use abstract classes for the “shared implementation plus mandatory customization” pattern; use interfaces for pure shape with no shared code.