<arch.design/>
Principles/Polymorphism
{ }CodeArchitectureintermediate1967oopruntime-dispatchinterfacegenerics

Polymorphism

Allow different types to be used interchangeably through a shared interface — so the calling code doesn't need to know which concrete type it's working with.

4/5
{ }
Operates at: Code level

Inside a codebase — classes, modules, files

How it works

Polymorphism (Greek: 'many forms') lets a single piece of code work with many different types, as long as they implement the expected interface. The two main forms in OOP are:

**Subtype polymorphism (runtime)**: a variable typed as an interface or base class can hold any conforming implementation. The correct method is dispatched at runtime based on the actual type.

**Parametric polymorphism (compile-time / generics)**: a function is written once and works with any type that satisfies a constraint (TypeScript generics, Rust traits, Go type parameters).

Polymorphism is what makes Strategy, Observer, and Dependency Injection work. A PaymentProcessor variable can hold a StripeProcessor or PayPalProcessor — the checkout function doesn't care which. At runtime, the right charge() implementation is called. Adding a new payment method means adding a new class, not changing the checkout code.

Implementation

TypeScript · Go · Rust
interface PaymentProcessor {
  charge(amount: number, currency: string): Promise<string>;
}

class StripeProcessor implements PaymentProcessor {
  async charge(amount: number, currency: string): Promise<string> {
    // Stripe API call
    return `stripe_txn_${Date.now()}`;
  }
}

class PayPalProcessor implements PaymentProcessor {
  async charge(amount: number, currency: string): Promise<string> {
    // PayPal API call
    return `paypal_txn_${Date.now()}`;
  }
}

// Adding a new processor = adding a new class, not changing checkout
class CryptoProcessor implements PaymentProcessor {
  async charge(amount: number, currency: string): Promise<string> {
    return `crypto_txn_${Date.now()}`;
  }
}

// Same code path regardless of which processor is injected
async function checkout(processor: PaymentProcessor, total: number): Promise<string> {
  return processor.charge(total, "USD");
}

Why it matters

Code that uses concrete types directly must change every time a new variant is added. Code that uses a polymorphic interface stays unchanged — only the implementation list grows. Polymorphism is the runtime expression of the Open/Closed Principle.

When to use

  • Multiple interchangeable implementations of a concept (renderers, payment processors, loggers)
  • When new variants must be addable without changing existing code
  • Eliminating type-switch or if/else chains conditioned on type
  • Plugin architectures and extensibility points

When NOT to use

  • When there is genuinely only one implementation and no extension is planned
  • Simple data containers with no behaviour don't benefit from polymorphic interfaces

Trade-offs

+

New implementations can be added without changing calling code

Dynamic dispatch has a small runtime cost vs direct method calls

+

Eliminates type-checking conditionals and switch statements

Too many small interfaces add indirection without clarity

+

Enables substitution — the basis of testability via test doubles

Abuse leads to deep hierarchies that are hard to follow

In production

Java Collections

List<T> is polymorphic — ArrayList and LinkedList are interchangeable; code written to List works with either

Go io.Reader/Writer

Everything that can be read implements io.Reader — files, network connections, in-memory buffers, all interchangeable

React renderers

react-dom, react-native, react-three-fiber all implement the same React component interface — same components render everywhere

Industry adoption

4/5Widely adopted — mainstream at medium-to-large engineering orgs.

Related principles