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.
Table of Contents
- 1. Why abstract classes in TypeScript are their own tool
- 2. Syntax: the abstract keyword for classes and methods
- 3. Abstract class or interface: making the right choice
- 4. Practical example: an abstract Repository base class
- 5. Concrete subclasses: ProductRepository and OrderRepository
- 6. Compilation: how TypeScript resolves abstraction into JavaScript
- 7. Runtime behavior: TypeScript versus PHP for abstract classes
- 8. Common pitfalls with abstract classes
- 9. Abstract class vs. interface compared side by side
- 10. Summary
- 11. FAQ
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.