TypeScript Abstract Classes vs Interfaces in 2026: Which to Reach For and When
This article was written with the assistance of AI, under human supervision and review.
Most TypeScript engineers misuse abstract classes and interfaces because they treat them as interchangeable. The confusion stems from a superficial understanding: both appear to define contracts for objects, both enforce implementation requirements, and both support polymorphism. Teams reach for abstract classes when interfaces would suffice, or worse, they use interfaces where shared behavior demands an abstract base. The cost shows up as duplicated logic, brittle hierarchies, and runtime errors that TypeScript's type system should have prevented.
The distinction matters because these tools serve fundamentally different purposes. Interfaces define pure structural contracts—shapes that objects must match without dictating implementation. Abstract classes combine contracts with executable code, providing shared behavior and state that subclasses inherit. Choosing correctly means the difference between a flexible system that adapts to requirements and a rigid codebase that fights every change.
The correct approach matches the tool to the problem. When the requirement is a contract without implementation—a shape that multiple unrelated classes satisfy—interfaces deliver maximum flexibility with zero runtime overhead. When the requirement includes shared behavior or state that subclasses must inherit, abstract classes enforce the contract while eliminating duplication.
Key Takeaways
- Interfaces define pure structural contracts with zero runtime footprint, making them ideal for enforcing object shapes across unrelated classes without imposing inheritance.
- Abstract classes combine contracts with executable shared behavior and state, eliminating duplication when subclasses require common implementation logic.
- The choice hinges on a single question: does the contract require shared code? If yes, abstract class; if no, interface.
- Interfaces compile away entirely; abstract classes produce JavaScript constructors and inheritance chains that impact bundle size and memory.
- Hybrid patterns—abstract classes implementing interfaces—provide maximum flexibility by separating contract definition from base implementation.
When to Use Interfaces: Contracts Without Implementation
Interfaces excel when the requirement is structural conformance. When multiple unrelated classes need to expose the same shape but implement behavior independently, interfaces enforce the contract without coupling implementations through inheritance. This matters for teams building plugin systems, event-driven architectures, or any codebase where flexibility trumps code reuse.
The key advantage is compile-time enforcement with zero runtime cost. TypeScript erases interfaces entirely during compilation—they produce no JavaScript, consume no memory, and impose no inheritance hierarchy. A class can implement dozens of interfaces without runtime penalty, and objects can satisfy interfaces without explicitly declaring them through structural typing.
Interfaces support multiple implementation—a class can implement several interfaces simultaneously, enabling composition over inheritance. This pattern appears frequently in dependency injection systems where services must satisfy multiple contracts. The TypeScript compiler verifies that every required property and method exists with correct signatures, catching contract violations before runtime.
The limitation is that interfaces cannot provide default implementations. Every class implementing an interface must define every method, even when implementations are identical. When three classes need the same validation logic, interfaces force duplication. The temptation to copy-paste shared code signals that an abstract class might be the better choice.
When to Use Abstract Classes: Shared Behavior and State
Abstract classes serve a different purpose: they combine contract enforcement with executable code that subclasses inherit. When multiple classes require both a common interface and shared implementation, abstract classes eliminate duplication while maintaining type safety. The pattern appears in framework development, plugin architectures with common utilities, and any domain where subclasses share substantial logic.
The core capability is partial implementation. Abstract classes define methods with actual code that subclasses inherit and optionally override. Protected members provide encapsulated state that subclasses access but external consumers cannot. Abstract methods—declared without implementation—force subclasses to define specific behavior while inheriting the rest.
The tradeoff is reduced flexibility compared to interfaces. Abstract classes enforce single inheritance—a class extends exactly one abstract base. When requirements demand multiple base implementations, the hierarchy collapses. Interfaces allow a class to implement multiple contracts simultaneously; abstract classes impose a single inheritance chain.
Runtime overhead is the other consideration. Abstract classes compile to JavaScript constructors and prototype chains. Every instance carries the inheritance hierarchy in memory, and every method call traverses the prototype chain. The impact is negligible for typical applications but matters in performance-critical code processing millions of objects. Bundle size increases proportionally with the number of base methods and properties.
Real-World Code Examples: Interfaces in Action
The plugin system pattern demonstrates interfaces at scale. When a host application needs to accept third-party extensions without knowing their implementation, interfaces define the contract. Each plugin implements the required methods, but the host never depends on concrete classes—only the interface shape.
interface DataTransformer {
readonly name: string;
readonly version: string;
transform(input: unknown): Promise<unknown>;
validate(input: unknown): boolean;
}
class JsonTransformer implements DataTransformer {
readonly name = "json-transformer";
readonly version = "1.0.0";
async transform(input: unknown): Promise<unknown> {
if (typeof input !== "string") {
throw new Error("JsonTransformer requires string input");
}
return JSON.parse(input);
}
validate(input: unknown): boolean {
if (typeof input !== "string") return false;
try {
JSON.parse(input);
return true;
} catch {
return false;
}
}
}
class XmlTransformer implements DataTransformer {
readonly name = "xml-transformer";
readonly version = "1.2.0";
async transform(input: unknown): Promise<unknown> {
// XML parsing implementation
return { parsed: "xml-data" };
}
validate(input: unknown): boolean {
return typeof input === "string" && input.startsWith("<");
}
}
class TransformerRegistry {
private transformers = new Map<string, DataTransformer>();
register(transformer: DataTransformer): void {
this.transformers.set(transformer.name, transformer);
}
async process(name: string, input: unknown): Promise<unknown> {
const transformer = this.transformers.get(name);
if (!transformer) {
throw new Error(`Transformer ${name} not found`);
}
if (!transformer.validate(input)) {
throw new Error(`Invalid input for ${name}`);
}
return transformer.transform(input);
}
}
The registry depends only on the DataTransformer interface. New transformers integrate without modifying host code. The pattern scales to hundreds of plugins because interfaces impose no inheritance coupling. Each transformer implements the contract independently, using whatever internal implementation suits its requirements.
Event emitter systems follow the same principle. When multiple unrelated classes need to handle events, interfaces define the handler contract. The event system dispatches to any object matching the shape, regardless of its inheritance hierarchy.
interface EventHandler<T = unknown> {
handle(event: T): void | Promise<void>;
priority?: number;
}
class EventBus {
private handlers = new Map<string, EventHandler[]>();
on<T>(eventName: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(eventName) || [];
this.handlers.set(
eventName,
[...existing, handler].sort((a, b) =>
(b.priority || 0) - (a.priority || 0)
)
);
}
async emit<T>(eventName: string, event: T): Promise<void> {
const handlers = this.handlers.get(eventName) || [];
for (const handler of handlers) {
await handler.handle(event);
}
}
}
class LoggingHandler implements EventHandler<{ message: string }> {
priority = 100;
handle(event: { message: string }): void {
console.log(`[LOG] ${event.message}`);
}
}
class MetricsHandler implements EventHandler<{ message: string }> {
priority = 50;
async handle(event: { message: string }): Promise<void> {
// Send to metrics service
await fetch("/metrics", {
method: "POST",
body: JSON.stringify({ event: event.message })
});
}
}
Both handlers satisfy the EventHandler interface structurally. The event bus dispatches without caring about class hierarchies or implementation details. Adding a new handler requires no changes to existing code—just implement the interface and register.
Real-World Code Examples: Abstract Classes in Action
Form validation showcases abstract classes solving duplication. When multiple validators share formatting logic and error handling but differ in validation rules, abstract base classes provide the shared code. Subclasses inherit utilities and implement only the domain-specific validation.
abstract class BaseValidator<T> {
protected errors: string[] = [];
abstract validateRule(value: T): boolean;
validate(value: T): { valid: boolean; errors: string[] } {
this.errors = [];
const valid = this.validateRule(value);
return { valid, errors: [...this.errors] };
}
protected addError(message: string): void {
this.errors.push(message);
}
protected formatError(field: string, constraint: string): string {
return `${field} ${constraint}`;
}
}
class EmailValidator extends BaseValidator<string> {
private readonly emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
validateRule(value: string): boolean {
if (!value) {
this.addError(this.formatError("Email", "is required"));
return false;
}
if (!this.emailPattern.test(value)) {
this.addError(this.formatError("Email", "must be valid"));
return false;
}
return true;
}
}
class PasswordValidator extends BaseValidator<string> {
private readonly minLength = 8;
validateRule(value: string): boolean {
let valid = true;
if (!value) {
this.addError(this.formatError("Password", "is required"));
return false;
}
if (value.length < this.minLength) {
this.addError(
this.formatError("Password", `must be at least ${this.minLength} characters`)
);
valid = false;
}
if (!/[A-Z]/.test(value)) {
this.addError(this.formatError("Password", "must contain uppercase letter"));
valid = false;
}
if (!/[0-9]/.test(value)) {
this.addError(this.formatError("Password", "must contain number"));
valid = false;
}
return valid;
}
}
class FormValidator {
validate(
fields: { email: string; password: string }
): { valid: boolean; errors: Record<string, string[]> } {
const emailResult = new EmailValidator().validate(fields.email);
const passwordResult = new PasswordValidator().validate(fields.password);
return {
valid: emailResult.valid && passwordResult.valid,
errors: {
email: emailResult.errors,
password: passwordResult.errors
}
};
}
}
Both validators inherit error collection and formatting from BaseValidator. The protected addError and formatError methods eliminate duplication while keeping implementation details encapsulated. Each validator implements only validateRule, defining domain-specific logic without reimplementing infrastructure.
Repository patterns benefit similarly. When multiple repositories share connection management, transaction handling, and error logging but differ in query logic, abstract base classes provide the common infrastructure. Related content: TypeScript form validators explores advanced validation patterns.
abstract class BaseRepository<T> {
protected abstract tableName: string;
protected async query<R>(sql: string, params: unknown[]): Promise<R[]> {
// Shared connection pooling, error handling
console.log(`Executing: ${sql}`);
return [] as R[];
}
protected async transaction<R>(
fn: () => Promise<R>
): Promise<R> {
// Shared transaction management
console.log("Starting transaction");
try {
const result = await fn();
console.log("Committing transaction");
return result;
} catch (error) {
console.log("Rolling back transaction");
throw error;
}
}
async findById(id: string): Promise<T | null> {
const results = await this.query<T>(
`SELECT * FROM ${this.tableName} WHERE id = ?`,
[id]
);
return results[0] || null;
}
}
class UserRepository extends BaseRepository<{ id: string; email: string }> {
protected tableName = "users";
async findByEmail(email: string): Promise<{ id: string; email: string } | null> {
const results = await this.query<{ id: string; email: string }>(
`SELECT * FROM ${this.tableName} WHERE email = ?`,
[email]
);
return results[0] || null;
}
async createUser(email: string): Promise<{ id: string; email: string }> {
return this.transaction(async () => {
const id = crypto.randomUUID();
await this.query(
`INSERT INTO ${this.tableName} (id, email) VALUES (?, ?)`,
[id, email]
);
return { id, email };
});
}
}
The base repository handles connection management, transactions, and common queries. Subclasses inherit the infrastructure and add domain-specific methods. The pattern eliminates hundreds of lines of duplicated database logic across repositories.
Side-by-Side Comparison: Performance, Flexibility, and Trade-offs
The architectural differences produce measurable performance and flexibility implications. Understanding these tradeoffs informs the decision when both approaches appear viable.
Runtime performance favors interfaces in object creation and memory consumption. Interfaces compile away entirely—the TypeScript compiler erases them during transpilation. An object implementing three interfaces consumes the same memory as an object implementing zero interfaces. Abstract classes generate JavaScript constructors and prototype chains that occupy memory and add indirection to method calls.
The difference manifests in high-volume scenarios. Creating ten thousand objects from an abstract class hierarchy allocates memory for the entire inheritance chain. The same objects satisfying interfaces through structural typing carry no inheritance overhead. Method calls on interface-satisfying objects resolve directly; calls on abstract subclass instances traverse prototype chains.
Bundle size follows the same pattern. Interfaces contribute zero bytes to the production bundle. Abstract classes compile to JavaScript constructor functions and method definitions that occupy space proportional to the base implementation. A codebase with twenty abstract base classes might carry ten kilobytes of inheritance infrastructure that interfaces would eliminate.
Flexibility tilts toward interfaces. A class can implement dozens of interfaces simultaneously, composing behavior from multiple sources. Abstract classes enforce single inheritance—exactly one base class. When a class needs behavior from two abstract bases, the architecture breaks. The workaround—composition over inheritance—often signals that interfaces were the better choice initially.
Code reuse is where abstract classes dominate. When five classes share identical helper methods, abstract base classes eliminate duplication with zero cost to call sites. Interfaces force each class to implement helpers independently, or push shared code to external utility modules. The choice between duplicated implementation and awkward utility imports disappears with abstract classes.
Type safety is equivalent. Both approaches catch contract violations at compile time. Both support generic constraints and complex type relationships. The compiler verifies that classes satisfy their contracts whether those contracts come from interfaces or abstract base classes.
Practical Decision Framework: Choosing the Right Tool
The decision reduces to a flowchart that teams can apply consistently. Start with the requirement: does the contract include shared behavior or state that multiple classes need?
If the answer is no—the requirement is purely structural conformance without shared implementation—reach for interfaces. The pattern appears in plugin systems, event handlers, strategy patterns, and anywhere multiple unrelated classes expose the same shape. Interfaces provide maximum flexibility with zero runtime cost.
If the answer is yes—multiple classes require both a contract and shared executable code—consider whether those classes also need to implement other contracts. When a class needs behavior from a single abstract base and implements no other interfaces, abstract classes eliminate duplication without sacrificing type safety.
When a class requires both shared base behavior and multiple interface contracts, hybrid patterns provide the solution. An abstract class can implement one or more interfaces while providing shared code. Subclasses inherit the implementation and satisfy all interface contracts through the base class.
interface Serializable {
serialize(): string;
deserialize(data: string): void;
}
interface Validatable {
validate(): boolean;
}
abstract class BaseEntity implements Serializable, Validatable {
abstract id: string;
serialize(): string {
return JSON.stringify(this);
}
deserialize(data: string): void {
Object.assign(this, JSON.parse(data));
}
validate(): boolean {
return Boolean(this.id);
}
}
class User extends BaseEntity {
id: string;
email: string;
constructor(id: string, email: string) {
super();
this.id = id;
this.email = email;
}
validate(): boolean {
return super.validate() && Boolean(this.email);
}
}
The hybrid pattern combines interface flexibility with abstract class code reuse. BaseEntity satisfies both Serializable and Validatable interfaces while providing shared serialization logic. Subclasses inherit the implementation and extend validation as needed. External code can depend on either interface without knowing about the abstract base.
Performance-critical paths warrant special consideration. When code processes millions of objects per second, interface-based structural typing outperforms abstract class hierarchies. The memory overhead of prototype chains becomes measurable at scale. Profile before optimizing, but when profiling shows inheritance as a bottleneck, interfaces eliminate the cost.
Legacy codebases with deep inheritance hierarchies often benefit from incremental migration toward interfaces. Extract shared behavior into utility functions or composition patterns, define interfaces for existing contracts, and gradually replace abstract bases with interface implementations. The refactoring reduces coupling and improves testability without requiring a complete rewrite. Related content: TypeScript utility types covers type-level composition patterns.
Combining Both: Hybrid Patterns for Complex Systems
Production systems rarely fit clean either-or patterns. Complex domains require both contract enforcement and shared behavior across partially overlapping class hierarchies. Hybrid patterns—abstract classes implementing interfaces, with composition for cross-cutting concerns—provide architectural flexibility without sacrificing type safety or code reuse.
The key insight is that interfaces and abstract classes address orthogonal concerns. Interfaces define what objects can do—the contracts they satisfy. Abstract classes define how related objects implement shared behavior—the inheritance hierarchy. Combining both means separating contract definition from implementation strategy.
interface Repository<T> {
findById(id: string): Promise<T | null>;
save(entity: T): Promise<void>;
delete(id: string): Promise<void>;
}
interface Cacheable {
getCacheKey(id: string): string;
invalidateCache(id: string): void;
}
abstract class CachedRepository<T> implements Repository<T>, Cacheable {
protected abstract tableName: string;
private cache = new Map<string, T>();
getCacheKey(id: string): string {
return `${this.tableName}:${id}`;
}
invalidateCache(id: string): void {
this.cache.delete(this.getCacheKey(id));
}
async findById(id: string): Promise<T | null> {
const cacheKey = this.getCacheKey(id);
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
const entity = await this.fetchFromDb(id);
if (entity) {
this.cache.set(cacheKey, entity);
}
return entity;
}
protected abstract fetchFromDb(id: string): Promise<T | null>;
async save(entity: T & { id: string }): Promise<void> {
await this.saveToDb(entity);
this.cache.set(this.getCacheKey(entity.id), entity);
}
protected abstract saveToDb(entity: T): Promise<void>;
async delete(id: string): Promise<void> {
await this.deleteFromDb(id);
this.invalidateCache(id);
}
protected abstract deleteFromDb(id: string): Promise<void>;
}
The pattern separates concerns cleanly. The Repository interface defines the public contract that all repositories satisfy. The Cacheable interface defines cache management operations. CachedRepository implements both interfaces while providing shared caching logic. Subclasses implement only database operations—fetching, saving, and deleting—inheriting cache management for free.
External code depends on interfaces, not the abstract base. A service accepting Repository<User> works with any implementation—cached, uncached, or mock. The flexibility supports testing, allows performance optimizations without API changes, and enables gradual migration between implementation strategies.
Decorator patterns extend the approach when cross-cutting concerns multiply. Instead of a single abstract base implementing all interfaces, separate decorators handle individual concerns. Each decorator implements the core interface and wraps another implementation, adding behavior without inheritance coupling.
class LoggingRepository<T> implements Repository<T> {
constructor(private inner: Repository<T>) {}
async findById(id: string): Promise<T | null> {
console.log(`Finding entity ${id}`);
const result = await this.inner.findById(id);
console.log(`Found: ${result ? "yes" : "no"}`);
return result;
}
async save(entity: T): Promise<void> {
console.log("Saving entity");
await this.inner.save(entity);
console.log("Saved successfully");
}
async delete(id: string): Promise<void> {
console.log(`Deleting entity ${id}`);
await this.inner.delete(id);
console.log("Deleted successfully");
}
}
Decorators compose freely—wrap a repository in logging, then caching, then metrics. Each decorator remains simple, focused on a single concern. The pattern scales to dozens of cross-cutting behaviors without creating unmanageable inheritance hierarchies. Related content: TypeScript decorators explores decorator patterns in depth.
Frequently Asked Questions
Can a class implement multiple interfaces and extend an abstract class simultaneously?
Yes, TypeScript supports this pattern without restriction. A class can extend exactly one abstract class while implementing any number of interfaces, inheriting shared behavior from the base class while satisfying multiple contracts through interfaces.
Do interfaces have any runtime performance impact?
No, interfaces compile away entirely during TypeScript transpilation and produce zero JavaScript. They impose no memory overhead, no prototype chain traversal, and no bundle size increase—purely compile-time type checking.
When should abstract classes implement interfaces?
When you need both a public contract for external consumers and shared implementation for subclasses. The interface defines the API that all implementations satisfy; the abstract class provides base behavior that subclasses inherit.
Can abstract classes have non-abstract methods?
Yes, abstract classes support both abstract methods (declared without implementation, forcing subclasses to define them) and concrete methods (with full implementation that subclasses inherit and optionally override).
How do I migrate from abstract classes to interfaces without breaking changes?
Define an interface matching the abstract class's public API, make the abstract class implement that interface, and gradually update consumers to depend on the interface type instead of the class type. Once all references use the interface, you can replace the abstract base with alternative implementations.
That covers the essential patterns for abstract classes versus interfaces in TypeScript. The decision hinges on whether your contract requires shared executable code or purely structural conformance. Apply these patterns in production and the difference—fewer bugs, clearer intent, and faster iteration—will be immediate. When inheritance couples implementations too tightly, interfaces provide the flexibility to compose behavior from multiple sources. When duplication costs accumulate across related classes, abstract bases eliminate it without sacrificing type safety.






Top comments (0)