Skip to main content

A Simple Guide to TypeScript Abstract Classes

Understanding abstract classes is important when working with TypeScript because they help you build maintainable and extensible code. Abstract classes act as templates for other classes, setting required methods and properties without implementing them. In this guide, we'll explore TypeScript abstract classes, covering their definition, syntax, uses, and best practices.

What Are Abstract Classes in TypeScript?

In TypeScript, abstract classes are defined with the abstract keyword. These classes can contain both abstract methods (without implementations) and concrete methods (with implementations). Here's a basic example:

abstract class Animal {
abstract sound(): void;
}

The Animal class includes an abstract method sound(), which must be implemented by any class that extends it. This pattern enforces a contract that all subclasses must follow, making your code more reliable and typescript type-safe.

When working with Convex, understanding abstract classes helps you create better data models and API structures that enforce consistent behavior across your application.

Ensuring Method Implementation with Abstract Classes

Abstract classes can include methods without implementations, marked with the abstract keyword. Any class extending an abstract class must implement these methods:

abstract class Notification {
abstract send(): void;
}

class EmailNotification extends Notification {
send(): void {
console.log("Sending email notification...");
}
}

Here, the Notification class has an abstract method send(), implemented by EmailNotification. If a subclass fails to implement an abstract method, the TypeScript compiler will flag an error, preventing bugs before they happen.

This approach is similar to how Convex helpers create reusable patterns for common operations while requiring specific implementations for each use case.

Extending Abstract Classes

To extend an abstract class, use the extends keyword. Subclasses inherit all properties and methods, and can add new ones or override existing ones. Here's an example:

abstract class Shape {
abstract area(): number;
}

class Circle extends Shape {
private radius: number;

constructor(radius: number) {
super();
this.radius = radius;
}

area(): number {
return Math.PI * this.radius ** 2;
}
}

Here, Circle extends Shape and provides the area() method. The inheritance pattern ensures that all shapes have an area() method while allowing each shape to calculate its area differently.

This pattern helps you create consistent data access layers with proper type safety throughout your full-stack TypeScript application.

Abstract Classes vs. Interfaces

Both abstract classes and interfaces define blueprints for other classes, but they have key differences:

  1. Abstract classes can have both abstract and concrete methods, while interfaces only define method signatures
  2. Abstract classes can include properties with implementations, whereas interfaces only define property types
  3. A class can implement multiple interfaces but extend only one abstract class

Consider this example:

interface Printable {
print(): void;
}

abstract class Document implements Printable {
abstract print(): void;

protected abstract get content(): string;

format(): string {
return this.content.trim();
}
}

class TextDocument extends Document {
private text: string;

constructor(text: string) {
super();
this.text = text;
}

print(): void {
console.log(this.text);
}

get content(): string {
return this.text;
}
}

The Document class implements the Printable interface while adding its own abstract method and a concrete format() method. This combination of interface and abstract class creates a flexible yet structured foundation for document handling.

This approach aligns with Convex's schema validation practices, where you define type constraints while providing reusable implementation patterns.

Using Abstract Methods

Subclasses must implement abstract methods. Here's an example:

abstract class Vehicle {
abstract startEngine(): void;

protected abstract get engineType(): string;

describe(): string {
return `This vehicle has a ${this.engineType} engine`;
}
}

class Car extends Vehicle {
startEngine(): void {
console.log("Starting car engine...");
}

get engineType(): string {
return "Gasoline";
}
}

In this example, Car must implement both the startEngine() method and the engineType getter. The abstract class provides the describe() method that uses the subclass implementation of engineType.

This pattern is similar to how Convex custom functions let you define implementation requirements while providing shared functionality.

Constructors in Abstract Classes

Abstract classes can have constructor methods to initialize shared properties:

abstract class Shape {
protected dimensions: number[];

constructor(dimensions: number[]) {
this.dimensions = dimensions;
}

abstract area(): number;
}

class Rectangle extends Shape {
constructor(width: number, height: number) {
super([width, height]);
}

area(): number {
const [width, height] = this.dimensions;
return width * height;
}
}

The Rectangle class calls the Shape constructor via super(), passing the required dimensions. This approach centralizes common initialization logic while ensuring each subclass provides its specific implementation details.

When working with Convex schemas, this pattern helps you create consistent data validation and initialization across related data types.

Building a Class Hierarchy

Abstract classes can create complex hierarchies. Here's an example:

abstract class Animal {
abstract sound(): void;

move(): void {
console.log("Moving...");
}
}

abstract class Mammal extends Animal {
abstract eat(): void;

giveBirth(): void {
console.log("Giving birth to live young");
}
}

class Dog extends Mammal {
sound(): void {
console.log("Woof!");
}

eat(): void {
console.log("Eating dog food...");
}
}

In this hierarchy, Dog extends Mammal, which extends Animal. Each class in the chain adds behavior while ensuring proper implementation of abstract methods.

This hierarchical approach resembles how Convex API generation creates structured access to your backend functions with full type safety.

Common Challenges

When working with abstract classes in TypeScript, you might encounter these challenges:

  • Choosing Between Abstract Classes and Interfaces: Choose abstract classes when you need shared implementation code; use interfaces for pure type contracts. For example, use an abstract HttpClient class when you need common fetch logic, but an ApiEndpoint interface when you only need type definitions.
  • Implementing Methods: Ensure all abstract methods have implementations in concrete classes. The TypeScript compiler will help catch these errors:
abstract class Logger {
abstract log(message: string): void;
}

// Error: Class 'ConsoleLogger' must implement inherited abstract member 'log'
class ConsoleLogger extends Logger {
// Missing log implementation
}
  • Initialization Complexity: With multiple inheritance levels, managing constructor calls can be tricky. Create clear initialization patterns:
abstract class Base {
protected name: string;

constructor(name: string) {
this.name = name;
}
}

abstract class Derived extends Base {
protected id: number;

constructor(name: string, id: number) {
super(name); // Always call super() first
this.id = id;
}
}

Final Thoughts about Abstract Classes

Abstract classes in TypeScript provide a structured way to define shared behavior while enforcing implementation requirements. They bridge the gap between interfaces and concrete classes, offering both type safety and code reuse.

Use abstract classes when you need a common foundation with specific implementation requirements across related classes. As you build more complex TypeScript applications, these patterns will help you create maintainable, extensible code.