Abstract Class in TypeScript - Powerful Tool or Wrong Abstraction?

One of the most common design mistakes in TypeScript is declaring an abstract class that only exposes static methods.

At first glance, it looks elegant. In practice, it usually signals confusion between two different goals:

  • modeling behavior through inheritance and polymorphism
  • exposing utility functions or static configuration

If your class does not need instance-level behavior, an abstract class is often the wrong tool.

A quick before/after example

Here is the pattern that often appears in real codebases:

export abstract class ShippingFeeConfig {
  public static getConfig(): ShippingFeeRule[] {
    return SHIPPING_FEE_CONFIG;
  }
}

A clearer version for the same behavior:

export const SHIPPING_FEE_CONFIG: ShippingFeeRule[] = [
  { minWeightKg: 0, maxWeightKg: 2, feeUsd: 4.99 },
  { minWeightKg: 2.01, maxWeightKg: 5, feeUsd: 8.99 }
];

export function getShippingFeeConfig(): ShippingFeeRule[] {
  return SHIPPING_FEE_CONFIG;
}

Same outcome, less conceptual overhead.

Why abstract class + static is usually a smell

An abstract class exists to define an instance contract and force subclasses to implement it. Static methods live on the class itself, not on instances.

That combination creates friction:

  1. No real polymorphism: static members are not polymorphic in the same way instance methods are.
  2. No instantiation anyway: abstract classes cannot be instantiated, but static methods do not need instances in the first place.
  3. Confusing intent: readers expect inheritance-based behavior, but get utility-style usage.

If all consumers call ShippingFeeConfig.getConfig(), you’re not leveraging abstraction. You’re just wrapping a function in class syntax.

When abstract classes are the right choice

Abstract classes become valuable when you need multiple implementations with shared rules.

1) Multiple strategies behind one contract

export type ShippingFeeRule = {
  minWeightKg: number;
  maxWeightKg: number;
  feeUsd: number;
};

export abstract class ShippingFeeConfig {
  public abstract getConfig(): ShippingFeeRule[];
}

export class StandardShippingFeeConfig extends ShippingFeeConfig {
  public getConfig(): ShippingFeeRule[] {
    return [
      { minWeightKg: 0, maxWeightKg: 2, feeUsd: 4.99 },
      { minWeightKg: 2.01, maxWeightKg: 5, feeUsd: 8.99 }
    ];
  }
}

export class ExpressShippingFeeConfig extends ShippingFeeConfig {
  public getConfig(): ShippingFeeRule[] {
    return [
      { minWeightKg: 0, maxWeightKg: 2, feeUsd: 9.99 },
      { minWeightKg: 2.01, maxWeightKg: 5, feeUsd: 14.99 }
    ];
  }
}

2) Shared logic in the base class

export abstract class ShippingFeeConfig {
  public abstract getConfig(): ShippingFeeRule[];

  protected findByWeight(weightKg: number): ShippingFeeRule | undefined {
    return this.getConfig().find((rule) => {
      return (
        rule.minWeightKg <= weightKg &&
        rule.maxWeightKg >= weightKg
      );
    });
  }

  protected isSupportedDestinationZone(zone: string): boolean {
    return ["domestic", "north-america", "international"].includes(zone);
  }
}

Now the abstraction carries meaningful value: a contract plus reusable behavior.

Better alternatives for static-only use cases

If your data is fixed configuration and you do not need subclassing, choose one of these:

Option 1: Plain class with static methods

export class ShippingFeeConfig {
  public static getConfig(): ShippingFeeRule[] {
    return SHIPPING_FEE_CONFIG;
  }
}

Option 2: Function + constant (often the cleanest)

export const SHIPPING_FEE_CONFIG: ShippingFeeRule[] = [
  { minWeightKg: 0, maxWeightKg: 2, feeUsd: 4.99 },
  { minWeightKg: 2.01, maxWeightKg: 5, feeUsd: 8.99 }
];

export function getShippingFeeConfig(): ShippingFeeRule[] {
  return SHIPPING_FEE_CONFIG;
}

This is usually the most explicit shape for configuration-oriented code.

Abstract class vs interface

This question always comes up, and the distinction matters:

  • Use an interface when you only need a contract (shape of behavior).
  • Use an abstract class when you need both a contract and shared implementation.
  • If there is no shared instance behavior, prefer interface + concrete classes or plain functions.

Quick decision guide

Use an abstract class when:

  • you need instance-level polymorphism
  • you want to enforce an implementation contract
  • subclasses share non-trivial behavior

Avoid an abstract class when:

  • everything is static
  • there are no real subclasses
  • you’re only exposing config data or pure helpers

Final take

abstract is not a marker for “cannot instantiate.” It is a design tool for polymorphic behavior.

If your design is static-only today, keep it simple with functions or a static utility class. If you genuinely need multiple runtime strategies tomorrow, move to a real abstract base with instance methods.