Using Abstract Classes in TypeScript Correctly
AI generated
<T>
type
TypeScript · OOP · Abstract Classes · Clean Architecture
Using Abstract Classes in TypeScript Correctly
When an abstract base class beats a plain interface

Abstract classes combine a binding contract with shared implementation, making them the right tool whenever several classes need to share the same base logic. This article covers the exact syntax, the difference from plain interfaces using a Repository base class as an example, and explains why TypeScript fully erases the abstraction at compile time, while PHP actually enforces it at runtime.

14 min. read abstract class · interface · Repository Pattern TypeScript 5.x · Strict Mode · ES2022

1. Why abstract classes in TypeScript are their own tool

TypeScript extends ECMAScript classes with a concept JavaScript does not natively have: classes that cannot be instantiated themselves, only extended as a base for subclasses. An abstract class combines two things in a single declaration, a binding contract of abstract methods that every subclass must implement, and concrete, already-finished implementation that all subclasses share. This closes a gap between plain interfaces, which carry no implementation at all, and ordinary classes, which are fully concrete and express no obligation to be extended.

In practice, this tool pays off wherever several classes share the same structural flow but differ in individual steps, for example payment gateways, validation chains, or data access layers in a headless commerce architecture built on Magento. Instead of repeating the same caching or validation code in every single repository or adapter class, an abstract class defines the shared logic exactly once and leaves only the data-source-specific details to the subclasses. This pattern is often called the template method pattern and is one of the main reasons abstract classes are so common in object-oriented TypeScript codebases.

2. Syntax: the abstract keyword for classes and methods

An abstract class is marked with the abstract keyword in front of the class declaration, for example abstract class Repository { }. Inside such a class, individual methods can also be declared abstract, but then without a method body, only a signature and a semicolon. The TypeScript compiler strictly enforces two rules: first, a class marked abstract may never be instantiated directly with new, the compiler reports the error "Cannot create an instance of an abstract class." Second, every concrete subclass must fully implement all inherited abstract methods, otherwise compilation fails as well.

Abstract classes may have a normal constructor, which is invoked via super() when a subclass is created, even though the base class itself is never instantiated directly. Beyond abstract methods, TypeScript 4.3 onward also allows abstract fields and even abstract property accessors, for example abstract readonly name: string;, forcing subclasses to assign a concrete value. Important in practice: a class that does not implement all inherited abstract members remains implicitly abstract itself and must also be marked with the keyword, otherwise the compiler reports an error.


// Abstract base class: cannot be instantiated directly
abstract class Shape {
  // Abstract method: no body, must be implemented by subclasses
  abstract getArea(): number;

  // Abstract property: subclasses must assign a concrete value
  abstract readonly label: string;

  // Concrete method: shared implementation for all subclasses
  describe(): string {
    return `${this.label} has an area of ${this.getArea().toFixed(2)}`;
  }
}

class Circle extends Shape {
  readonly label = 'Circle';

  constructor(private readonly radius: number) {
    super();
  }

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

// const shape = new Shape(); // Compile error: Cannot create an instance of an abstract class.
const circle = new Circle(4);
console.log(circle.describe());

3. Abstract class or interface: making the right choice

The most important structural difference: an interface carries only a contract, never implementation, and is completely removed at compile time, it literally leaves no trace in the generated JavaScript. A class can implement any number of interfaces at once, which allows genuine multiple inheritance of contracts. An abstract class, by contrast, produces a real JavaScript class with actual code, but is subject to JavaScript's single inheritance, a subclass can only ever extend exactly one base class, whether abstract or concrete.

This yields a clear decision rule: choose an interface when only a contract is needed between independent implementations that are otherwise unrelated, for example at dependency injection boundaries or for interchangeable strategies. Choose an abstract class as soon as several classes share substantial, reusable code and differ only in clearly delineated steps, especially when this also requires shared constructor logic or protected helper methods, which an interface fundamentally cannot express.

4. Practical example: an abstract Repository base class

A Repository base class is a vivid example of how abstract classes are used in a headless commerce architecture. The generic class Repository<T> defines three abstract hooks, fetchById, persist, and remove, which every concrete subclass must fill in with its own data source, whether a REST API, GraphQL, or local storage. The generic type parameter T is constrained via T extends { id: string }, so the base class can safely access an id property without knowing the concrete entity.

The real strength shows up in the base class's concrete, non-abstract methods: findById wraps shared caching with a Map and only calls the abstract fetchById hook on a cache miss, save runs a shared validation via an overridable validate method before the actual write. This combination of abstract hooks for data source access and concrete methods for caching and validation is the core pattern that distinguishes an abstract class from a plain interface.


// Generic abstract base class for a headless commerce data access layer
abstract class Repository<T extends { id: string }> {
  protected readonly cache = new Map<string, T>();

  // Abstract: each concrete repository knows its own data source
  protected abstract fetchById(id: string): Promise<T | null>;
  protected abstract persist(entity: T): Promise<void>;
  protected abstract remove(id: string): Promise<void>;

  // Concrete: shared caching logic, built on top of the abstract hooks
  async findById(id: string): Promise<T | null> {
    if (this.cache.has(id)) {
      return this.cache.get(id) ?? null;
    }
    const entity = await this.fetchById(id);
    if (entity) {
      this.cache.set(id, entity);
    }
    return entity;
  }

  async save(entity: T): Promise<void> {
    this.validate(entity);
    await this.persist(entity);
    this.cache.set(entity.id, entity);
  }

  async delete(id: string): Promise<void> {
    await this.remove(id);
    this.cache.delete(id);
  }

  // Concrete: shared validation, a template method subclasses can extend
  protected validate(entity: T): void {
    if (!entity.id) {
      throw new Error('Entity must have a non-empty id');
    }
  }
}

5. Concrete subclasses: ProductRepository and OrderRepository

ProductRepository extends Repository<Product> and implements the three abstract hooks through an injected API client that talks to the Magento REST endpoint under /rest/V1/products. OrderRepository follows exactly the same pattern for orders, but may internally use a different data source, for example a GraphQL query against the headless storefront. Both classes fully inherit caching and validation from the base class and never need to duplicate that code.

The benefit becomes especially clear once product-specific validation rules are added: ProductRepository overrides validate, but first calls the shared base check via super.validate(entity) and then adds a check that the price cannot be negative. This pattern, explicitly calling the inherited implementation and extending it rather than replacing it, is noticeably more robust in practice than copying validation logic across multiple repository classes, because changes to the base rule automatically take effect everywhere.


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

class ProductRepository extends Repository<Product> {
  constructor(private readonly apiClient: MagentoApiClient) {
    super();
  }

  protected async fetchById(id: string): Promise<Product | null> {
    return this.apiClient.get<Product>(`/rest/V1/products/${id}`);
  }

  protected async persist(entity: Product): Promise<void> {
    await this.apiClient.put(`/rest/V1/products/${entity.id}`, entity);
  }

  protected async remove(id: string): Promise<void> {
    await this.apiClient.delete(`/rest/V1/products/${id}`);
  }

  // Override the template method for product-specific validation
  protected validate(entity: Product): void {
    super.validate(entity);
    if (entity.price < 0) {
      throw new Error('Product price cannot be negative');
    }
  }
}

6. Compilation: how TypeScript resolves abstraction into JavaScript

A frequently overlooked aspect: TypeScript compiles abstract classes down to plain ES2022 classes. The abstract keyword on the class, the signatures of abstract methods, and even abstract fields exist exclusively in the type system, they never appear in the generated JavaScript at all. The rule the compiler enforces, that an abstract class cannot be created directly with new, is a purely compile-time check, in the output JavaScript there is no runtime check enforcing that rule whatsoever.

Concretely, this means: once the code is compiled, the formerly abstract class behaves like any other class, it can be instantiated with new without any issue. Anyone consuming a compiled TypeScript library from a plain JavaScript project, or deliberately bypassing the type with as any, undermines the protection entirely. For most internal codebases this is not a problem, since the compiler reliably warns at every typed call site, but for publicly distributed libraries, section eight is worth a closer look.


// Input (TypeScript): the "abstract" keyword and the method signature
// carry no runtime representation at all
abstract class PaymentGateway {
  abstract charge(amountCents: number): Promise<void>;

  logAttempt(amountCents: number): void {
    console.log(`Charging ${amountCents} cents`);
  }
}

// Output (compiled JavaScript, target ES2022): the abstract keyword,
// the abstract method signature, and the compile-time "new" check
// are all gone. Nothing distinguishes this class from a normal one.
class PaymentGateway {
  logAttempt(amountCents) {
    console.log(`Charging ${amountCents} cents`);
  }
}

// Perfectly legal at runtime, even though tsc would reject it:
const gateway = new PaymentGateway();

7. Runtime behavior: TypeScript versus PHP for abstract classes

For developers with a PHP background, this difference matters a great deal: PHP actually enforces a class's abstractness at runtime. Attempting new Repository() on a PHP class declared with abstract class Repository triggers a genuine fatal error, "Cannot instantiate abstract class Repository", regardless of whether the calling code ever ran through a type checker. TypeScript offers no such guarantee, its entire type checking, including the abstract class rules, disappears completely at compile time.

This asymmetry has practical consequences for library authors: an abstract class written in TypeScript reliably protects against mistakes made by other TypeScript consumers using the same compiler, but not against JavaScript consumers without type checking or against deliberate type bypasses. Anyone who needs a guarantee similar to PHP's cannot rely on the type system alone, and must implement the check explicitly at runtime instead, as shown in the following section.

8. Common pitfalls with abstract classes

One of the most common traps with abstract classes involves initialization order: if an abstract method is called directly from the base class constructor, that call runs before the subclass's field initializers have executed. If the overriding method in the subclass accesses a class field that is only initialized after super(), that access returns undefined at runtime instead of the expected value, a bug the compiler does not catch and that only surfaces at runtime. The safe fix: never call abstract methods from the base class constructor, only after construction is fully complete, for example via an explicit initialization method.

Anyone who needs genuine runtime protection against direct instantiation, for example because a library is also consumed by plain JavaScript code, can use the native new.target mechanism. new.target references the constructor function that was actually invoked, inside a subclass it points to the subclass, not the base class. A check like new.target === Repository in the base class constructor reliably detects whether the base class was called directly, and can then throw a genuine TypeError. Even without TypeScript, this pattern keeps working in compiled JavaScript, because it relies on native JavaScript runtime behavior and not on the type system.


abstract class StrictBase {
  constructor() {
    // Runtime guard: throws even if a plain-JS caller bypasses tsc
    if (new.target === StrictBase) {
      throw new TypeError('StrictBase is abstract and cannot be instantiated directly');
    }
  }

  abstract execute(): void;
}

class ConcreteJob extends StrictBase {
  execute(): void {
    console.log('Running concrete job');
  }
}

new ConcreteJob(); // OK: new.target is ConcreteJob
// new StrictBase(); // Throws TypeError at runtime, mirroring PHP's behavior

9. Abstract class vs. interface compared side by side

The table below summarizes the choice between abstract class and interface for five typical scenarios, each with the not-recommended and the recommended option.

Scenario Not recommended Recommended
Shared implementation needed Interface (no method bodies allowed) Abstract class
Multiple inheritance needed Abstract class (single inheritance only) Interface (multiple implements allowed)
Pure contract for dependency injection Abstract class (unnecessary coupling) Interface
Constructor logic needed Interface (no constructor possible) Abstract class
Runtime type checking needed (instanceof) Interface (fully erased at runtime) Abstract class

In practice, many TypeScript codebases deliberately combine both tools: an interface defines the public, interchangeable contract for dependency injection, while an abstract class behind it provides the shared implementation for a family of related classes. This combination, for example interface RepositoryContract<T> alongside abstract class Repository<T>, unites the strengths of both concepts without either one having to replace the other.

Mironsoft

TypeScript architecture and headless commerce development for Magento stores

Ready to implement TypeScript architecture professionally?

We design and implement clean TypeScript architectures for headless commerce frontends and build tooling around Magento, from abstract base classes to type-safe API layers.

TypeScript architecture review

Analysis of existing class hierarchies, interfaces, and abstract classes for maintainability and testability

Headless commerce integration

Repository layers, API clients, and type-safe data models for Magento headless frontends

Build and tooling setup

TypeScript configuration, strict mode, and CI pipelines for stable frontend projects

10. Summary

Abstract classes in TypeScript solve a concrete problem: they enforce a contract of abstract methods and fields, while at the same time providing concrete, reusable implementation for all subclasses, for example shared caching and validation in a Repository base class. The choice between abstract class and interface almost always depends on whether substantial code needs to be shared, in which case abstract class, or whether only an interchangeable contract is needed, in which case interface, especially when a class must fulfill several such contracts at once.

The decisive difference from PHP must not be overlooked: TypeScript's protection against direct instantiation is exclusively a compile-time check that fully disappears when compiling to ES2022, while PHP actually enforces the same rule at runtime with a fatal error. Anyone who needs genuine runtime protection, for example for a publicly distributed library, adds an explicit new.target check to the abstract class constructor and thereby achieves PHP-like behavior, without leaving the type system at all.

Abstract Classes in TypeScript - The Essentials at a Glance

Syntax

abstract before class and methods, no direct new allowed, constructor allowed via super().

Abstract class vs. interface

Shared implementation and constructor logic: abstract class. Pure contract and multiple inheritance: interface.

Repository pattern

Abstract findById/save/delete hooks with shared caching and validation logic in the base class.

Compilation & runtime

TypeScript fully erases abstract; new.target as a manual guard when PHP-like protection is needed.

11. FAQ: Abstract Classes in TypeScript

1What is an abstract class in TypeScript?
A class marked with abstract that cannot be instantiated directly and can contain both abstract methods without implementation and concrete methods with a finished implementation.
2How do I declare an abstract method?
With abstract before the method and no method body, only a signature and semicolon, e.g. abstract getArea(): number;. Every concrete subclass must implement it.
3Can an abstract class have a constructor?
Yes. The constructor is invoked via super() from the constructor of every subclass, even though the base class itself is never instantiated directly.
4When should I use an abstract class instead of an interface?
When several classes share substantial, reusable implementation, such as caching or validation, or when shared constructor logic is required.
5Can a class extend multiple abstract classes?
No. Only single inheritance is allowed, a class extends exactly one base class, but can implement any number of interfaces in addition.
6How does TypeScript compile abstract classes to JavaScript?
abstract, plus the signatures of abstract methods and fields, are fully removed. What remains is an ordinary class with no runtime trace of the original abstractness.
7Does TypeScript throw a runtime error when instantiating an abstract class?
No. The compiler only reports the error at compile time. In compiled JavaScript there is no runtime check, the class can be instantiated there without any issue.
8How does this differ from PHP's abstract classes?
PHP actually enforces the rule at runtime with a fatal error. TypeScript's protection is purely compile-time and fully disappears when compiled.
9What is a common mistake when calling abstract methods from the constructor?
The call runs before the subclass's field initializers. If the method accesses a field that is only initialized afterward, that access returns undefined at runtime.
10How do I enforce genuine runtime protection against instantiation?
With a new.target check in the base class constructor that throws a TypeError on direct instantiation. This pattern also works in compiled JavaScript.