TypeScript Abstract Class vs Interface: Which Should You Use?

Misunderstanding how the TypeScript compiler works under the hood is incredibly common. Just recently, a pull request crossed my desk where a developer had created a massive AbstractUser class to type-check a standard JSON payload coming from a backend API. When asked why they didn’t just use a standard interface, their reasoning was that they wanted to enforce a strict contract across the application. It was a classic case of applying traditional object-oriented rules to a structural typing system.

If you have spent any time writing object-oriented code in languages like Java or C#, you probably brought some baggage over to TypeScript. I know I did. In those languages, abstract classes and interfaces have strictly defined, often overlapping roles. But in the TypeScript ecosystem, the rules of the game are fundamentally different. JavaScript is a dynamically typed language, and TypeScript is just a static layer sitting on top of it. This architectural reality completely changes the math when deciding between a typescript abstract class vs interface.

Choosing the wrong abstraction doesn’t just make your code harder to read; it can bloat your bundle size, break your dependency injection containers in frameworks like NestJS, and make mocking dependencies for your TypeScript unit tests an absolute nightmare. In this deep dive, we will look at the mechanical differences, the performance implications, and the precise architectural scenarios where each of these constructs actually belongs.

The Compile-Time Ghost vs. The Runtime Heavyweight

To understand the core difference between an interface and an abstract class in TypeScript, you have to look at what happens when your code is fed through the TypeScript compiler (tsc) or your bundler of choice like Vite or Webpack.

An interface is a compile-time ghost—a purely structural contract that exists solely for the TypeScript language server and the compiler to enforce type safety while you write code. Once the build process runs, interfaces are completely erased. They leave zero footprint.

Consider this simple interface:

interface UserData {
  id: string;
  email: string;
  isActive: boolean;
}

const fetchUser = async (): Promise<UserData> => {
  const response = await fetch('/api/users/1');
  return response.json();
};

When you compile that code, the generated JavaScript looks like this:

const fetchUser = async () => {
    const response = await fetch('/api/users/1');
    return response.json();
};

The UserData interface vanishes. Zero bytes added to your production bundle.

An abstract class, however, is a runtime heavyweight. It is a real JavaScript class that happens to have some metadata telling TypeScript “do not let the developer instantiate this directly.” Because JavaScript (since ES2015) natively supports classes, an abstract class is compiled down into an actual class definition in your final bundle. It sits in memory, it occupies bytes in your network payload, and it carries an actual prototype chain.

Look at what happens when we use an abstract class instead:

abstract class BaseModel {
  abstract id: string;
  
  protected getTimestamp(): number {
    return Date.now();
  }
}

class User extends BaseModel {
  id = '123';
}

Here is the compiled JavaScript output:

class BaseModel {
    getTimestamp() {
        return Date.now();
    }
}
class User extends BaseModel {
    constructor() {
        super(...arguments);
        this.id = '123';
    }
}

The BaseModel is right there in the output. If you are shipping a frontend application where every kilobyte matters for performance, littering your codebase with abstract classes solely for the purpose of type-checking data shapes is a massive anti-pattern. You are shipping dead code to your users.

When Interfaces Are Your Best Friend

As a rule of thumb in TypeScript development, you should default to using interfaces. They are the native tongue of TypeScript’s structural typing system (often called “duck typing”). If it walks like a duck and quacks like a duck, TypeScript considers it a duck. Interfaces are perfect for this.

Here are the specific scenarios where interfaces absolutely dominate:

1. Defining Data Payloads and API Responses

When you are fetching data from a REST API or a GraphQL endpoint, you are receiving a Plain Old JavaScript Object (POJO). There are no methods attached to it. There is no prototype chain. It is just raw data. Using an abstract class to type this data forces you to manually instantiate the class using the incoming data, which is unnecessary overhead.

programming code on screen - Free Laptop coding display Image - Laptop, Coding, Programming ...

// Do this:
interface Product {
  sku: string;
  price: number;
  inStock: boolean;
}

// Don't do this:
abstract class AbstractProduct {
  abstract sku: string;
  abstract price: number;
  abstract inStock: boolean;
}

2. React Component Props

If you are working in a TypeScript React environment, interfaces are the standard for defining component props. React components (especially functional ones) take a single props object. This object is just a dictionary of values and functions.

import React from 'react';

interface ButtonProps {
  variant: 'primary' | 'secondary' | 'danger';
  label: string;
  onClick: () => void;
  isDisabled?: boolean;
}

export const Button: React.FC<ButtonProps> = ({ variant, label, onClick, isDisabled }) => {
  return (
    <button className={btn-${variant}} onClick={onClick} disabled={isDisabled}>
      {label}
    </button>
  );
};

Using an abstract class for React props would be entirely nonsensical. It would provide no value and would make passing inline objects impossible without violating strict mode type checks.

3. Multiple Inheritance via “Implements”

JavaScript classes only support single inheritance; you can only extend one class. However, a class can implement an infinite number of interfaces. If you are building a complex domain model, interfaces allow you to compose behaviors modularly.

interface Logger {
  logError(msg: string): void;
}

interface AnalyticsTracker {
  trackEvent(eventName: string): void;
}

// We can implement multiple interfaces easily
class UserActionService implements Logger, AnalyticsTracker {
  logError(msg: string) {
    console.error([Error]: ${msg});
  }

  trackEvent(eventName: string) {
    console.log([Event Tracking]: ${eventName});
  }
}

4. Declaration Merging

Interfaces have a unique superpower in TypeScript called declaration merging. If you define two interfaces with the exact same name in the same scope, TypeScript will automatically merge their properties. This is crucial when extending third-party libraries (like augmenting the Express Request object) or working with global browser APIs.

// File 1: core.ts
interface Window {
  appConfig: {
    apiUrl: string;
  };
}

// File 2: analytics.ts
interface Window {
  gtag: (command: string, targetId: string) => void;
}

// TypeScript merges these. Now window has both appConfig and gtag.
window.appConfig.apiUrl = "https://api.example.com";
window.gtag('config', 'GA-12345');

Abstract classes cannot do this. If you declare two abstract classes with the same name, the TypeScript compiler will throw a duplicate identifier error.

When Abstract Classes Actually Make Sense

Given everything mentioned about bundle size and interfaces, you might think abstract classes are entirely useless. Actually, I should clarify—when used correctly, they are a powerful tool in your TypeScript architecture. While interfaces are strictly for defining the shape of an object, abstract classes allow you to define both the shape and the baseline implementation.

You should reach for an abstract class when you have a family of classes that share complex, reusable logic, but require the child classes to fill in specific missing pieces. This is often referred to as the Template Method Pattern.

1. Sharing Protected State and Utility Methods

An interface cannot hold implementation details. It cannot have access modifiers like private or protected. If you have five different classes that all need to share the exact same setup logic, an interface will force you to duplicate that logic five times. An abstract class solves this.

abstract class BaseHttpClient {
  // Shared state protected from the outside world
  protected baseUrl: string;
  private retryCount: number = 3;

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

  // Shared implementation
  protected async handleResponse(response: Response) {
    if (!response.ok) {
      throw new Error(HTTP Error: ${response.status});
    }
    return response.json();
  }

  // Contract that MUST be fulfilled by the child class
  abstract getHeaders(): Record<string, string>;

  // Public method utilizing both shared logic and abstract requirements
  public async get(endpoint: string) {
    const response = await fetch(${this.baseUrl}${endpoint}, {
      headers: this.getHeaders()
    });
    return this.handleResponse(response);
  }
}

class AuthenticatedApiClient extends BaseHttpClient {
  constructor(baseUrl: string, private token: string) {
    super(baseUrl);
  }

  // Fulfilling the abstract contract
  getHeaders(): Record<string, string> {
    return {
      'Authorization': Bearer ${this.token},
      'Content-Type': 'application/json'
    };
  }
}

class PublicApiClient extends BaseHttpClient {
  getHeaders(): Record<string, string> {
    return {
      'Content-Type': 'application/json'
    };
  }
}

In this scenario, comparing a typescript abstract class vs interface heavily favors the abstract class. If we used an interface, AuthenticatedApiClient and PublicApiClient would both have to rewrite the get() and handleResponse() methods from scratch. That violates the DRY (Don’t Repeat Yourself) principle and creates maintenance headaches.

2. Dependency Injection Tokens (NestJS / Angular)

If you work in a backend environment like TypeScript NestJS or an Angular frontend, you rely heavily on Dependency Injection (DI) containers. These frameworks map a token to a specific implementation.

Because interfaces disappear at runtime, you cannot use them directly as injection tokens in TypeScript without using strings or symbols. Abstract classes, however, survive compilation and exist as runtime objects, making them perfect DI tokens.

// NestJS Example

// 1. Define the abstract class (acts as both contract and token)
export abstract class CacheService {
  abstract set(key: string, value: any): Promise<void>;
  abstract get(key: string): Promise<any>;
}

// 2. Concrete implementation
@Injectable()
export class RedisCacheService implements CacheService {
  async set(key: string, value: any) { /* Redis logic */ }
  async get(key: string) { /* Redis logic */ }
}

// 3. Module Configuration
@Module({
  providers: [
    {
      provide: CacheService, // The abstract class acts as the token!
      useClass: RedisCacheService,
    },
  ],
})
export class AppModule {}

// 4. Injection in a Controller
@Controller('users')
export class UsersController {
  // TypeScript knows the type, AND NestJS knows what to inject at runtime
  constructor(private cacheService: CacheService) {} 
}

This is a major architectural advantage. It keeps your DI configuration clean and strongly typed, avoiding the fragile magic strings often required when relying solely on interfaces for injection.

Head-to-Head: TypeScript Abstract Class vs Interface

To summarize the mechanical differences, let’s look at how they stack up directly against each other across key metrics in TypeScript projects.

  • Compilation Output: Interfaces compile to nothing (0 bytes). Abstract classes compile to standard JavaScript classes, increasing bundle size.
  • Implementation Logic: Interfaces cannot contain implementation (no method bodies). Abstract classes can contain fully implemented methods, properties, and constructors alongside abstract definitions.
  • Access Modifiers: Interfaces treat everything as public. You cannot use private or protected keywords in an interface. Abstract classes fully support public, private, and protected visibility constraints.
  • Inheritance Capabilities: A class or interface can extend multiple interfaces simultaneously. A class can only extend exactly one abstract class.
  • Instantiation: Neither can be instantiated directly via the new keyword. You cannot do new MyInterface() or new MyAbstractClass(). Both require a concrete class to implement or extend them.

The “Implements” Bridge: Using Both Together

One of the most robust TypeScript patterns I teach my teams is not to treat this as an either/or scenario. Often, the most resilient architectures use both in tandem. You use an interface to define the absolute highest-level contract, and then you use an abstract class to provide a base implementation of that interface.

Let’s look at a real-world scenario: refactoring a notification system. We want a strict contract for all notifications so our application code relies purely on abstractions, but we also want to share retry logic across different notification providers.

// 1. The pure contract (Interface)
interface INotificationService {
  send(userId: string, message: string): Promise<boolean>;
  getStatus(messageId: string): Promise<string>;
}

// 2. The base implementation (Abstract Class implementing the Interface)
abstract class BaseNotificationService implements INotificationService {
  protected maxRetries = 3;

  // We leave the actual sending to the specific provider
  abstract send(userId: string, message: string): Promise<boolean>;
  
  abstract getStatus(messageId: string): Promise<string>;

  // Shared utility logic available to all child classes
  protected async withRetry<T>(operation: () => Promise<T>): Promise<T> {
    let attempts = 0;
    while (attempts < this.maxRetries) {
      try {
        return await operation();
      } catch (error) {
        attempts++;
        if (attempts >= this.maxRetries) throw error;
        await new Promise(res => setTimeout(res, 1000 * attempts)); // Exponential backoff
      }
    }
    throw new Error("Operation failed after retries");
  }
}

// 3. The concrete implementation
class EmailNotificationService extends BaseNotificationService {
  async send(userId: string, message: string): Promise<boolean> {
    return this.withRetry(async () => {
      console.log(Sending email to ${userId}: ${message});
      // Actual SMTP logic here
      return true;
    });
  }

  async getStatus(messageId: string): Promise<string> {
    return "Delivered";
  }
}

class SMSNotificationService extends BaseNotificationService {
  async send(userId: string, message: string): Promise<boolean> {
    return this.withRetry(async () => {
      console.log(Sending SMS to ${userId}: ${message});
      // Actual Twilio logic here
      return true;
    });
  }

  async getStatus(messageId: string): Promise<string> {
    return "Pending";
  }
}

Why is this pattern so effective? It gives you the best of both worlds. If a developer wants to write a mock notification service for TypeScript unit tests using Jest, they can simply mock the INotificationService interface without inheriting all the heavy retry logic of the abstract class. Meanwhile, the actual production implementations get to share the complex backoff logic without duplicating code. This is the hallmark of a mature TypeScript application.

Type Checking and TypeScript Strict Mode Considerations

When you enable "strict": true in your TypeScript TSConfig—which you absolutely should be doing—the compiler becomes ruthless about how you implement both abstract classes and interfaces.

If you extend an abstract class, strict mode ensures you call super() in the constructor if you define one in the child class. It also strictly enforces that if an abstract method returns a Promise<string>, the child class cannot accidentally return a Promise<string | null> without throwing a compiler error.

With interfaces, strict mode ensures every single property defined in the interface is accounted for. If you define an interface with optional properties (e.g., description?: string), strict null checks will force you to handle the undefined case wherever you consume that interface. Abstract classes handle optionality differently; you can define a property with a default value in the abstract class, entirely bypassing the need for the child class to worry about it.

TypeScript Abstract Class vs Interface FAQ

Can an interface extend an abstract class in TypeScript?

Yes, surprisingly, it can. In TypeScript, an interface can extend a class (including an abstract class). When it does, it inherits the class’s members but not their implementations. It essentially extracts the shape of the abstract class, including private and protected members, which can be useful but often leads to confusing architectural designs.

Do abstract classes negatively affect my bundle size?

Yes, because abstract classes compile down to real JavaScript classes, they remain in your production bundle. However, the impact is usually negligible unless you have hundreds of massive abstract classes. Modern bundlers like Vite and Webpack perform dead code elimination (tree-shaking), but they often struggle to safely remove unused classes due to potential side effects in constructors.

Can I instantiate an abstract class using the “new” keyword?

No, you cannot directly instantiate an abstract class in TypeScript. Attempting to run new MyAbstractClass() will trigger a compile-time error. You must create a standard class that extends the abstract class, implements all of its abstract methods, and then instantiate that concrete child class.

Which is better for defining React component props?

Interfaces or Type Aliases are universally preferred for React props. React props are simply plain objects passed to functions. Using an abstract class for React props would require unnecessary instantiation, provide zero runtime benefit, and break standard React patterns.

The Final Verdict

The debate between a typescript abstract class vs interface ultimately comes down to understanding the boundaries between compile-time safety and runtime execution. If you are just describing the shape of data—like a JSON payload from an API, a configuration object, or React props—always use an interface. It keeps your code clean, flexible, and completely invisible in your final bundle.

Reserve abstract classes for genuine object-oriented architectures where you actually need to share executable behavior, manage protected state, or provide reliable tokens for dependency injection frameworks like NestJS. Stop trying to force Java or C# idioms into TypeScript where they don’t belong, and let the language’s structural typing system do what it does best.

Zahra Al-Farsi

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *