Polymorphism

Subtype, parametric, and ad-hoc. The procedural form of the Open-Closed Principle. The instanceof smell.

Concept Foundational
10 min read
oop polymorphism dispatch generics ocp

Summary#

Polymorphism is the property that lets one piece of code call the same method name on many different types and have the language do the right thing for each. The caller never asks “which kind is this?” — it calls the method on the abstract type it holds, and the runtime (or the compiler) dispatches to whichever concrete implementation is actually in play.

In one line: polymorphism is the procedural form of the Open-Closed Principle. Code organised around an abstract type plus polymorphic calls is open to extension — adding a new variant means writing a new class — and closed to modification — the existing call sites do not change.

Three forms appear consistently in LLD answers and the reviewer expects you to know all three by name:

Subtype (dynamic) polymorphism — different classes that share a parent or implement a common interface. Dispatch happens at runtime, on the actual class of the object. This is the form most people mean by “polymorphism” without qualifying it.

Parametric polymorphism — generics. List<T> works for any T. Dispatch happens at compile time by substituting the type parameter. The mechanism is different; the goal — letting one piece of code work for many types — is the same.

Ad-hoc polymorphism — overloading. Two methods with the same name but different parameter types; the compiler picks one based on the static types of the arguments. The weakest form, and the one most often confused with the first two.

Why it matters#

The reason polymorphism is one of the four pillars is the same reason instanceof chains are a code smell: every if (x instanceof T) is a missed opportunity to put the variant behaviour where it belongs — on T itself. Polymorphic dispatch is how object-oriented code stays open to new variants without rewriting old call sites.

Three things polymorphism buys you concretely:

Adding a variant doesn’t move existing code. A PaymentMethod interface with three implementations becomes one with four when a new gateway lands. The callers — Checkout, RefundService, the reporting code — see exactly zero edits. This is the property that compounds across a codebase: small, local changes for new requirements, instead of a search-and-replace across every call site.

Test doubles are free. A method that takes PaymentMethod accepts a real Stripe-backed one in production and an in-memory recording one in tests. No mocking framework needed; the polymorphism is the seam.

The dispatch table lives in one place — the language. Without polymorphism, the dispatch table is switch (x.type) repeated wherever a decision needs the variant. With polymorphism, the dispatch is the virtual method table; the language guarantees the lookup is total.

The strongest signal you can send on this pillar: when the reviewer adds a new variant, your existing methods do not change. Only a new class appears. That is polymorphism doing the work it was named to do.

How it works#

The mechanical move is to express the variation as a method on the abstract type, then call that method through the abstract type at every call site. A worked example: a notification system that has to deliver messages by email, SMS, and push, with the variant choice made once at the boundary and invisible to the rest of the system.

public interface NotificationChannel {
void send(Recipient to, Message m);
}
public final class EmailChannel implements NotificationChannel {
private final SmtpClient smtp;
public EmailChannel(SmtpClient smtp) { this.smtp = smtp; }
@Override
public void send(Recipient to, Message m) {
smtp.send(to.emailAddress(), m.subject(), m.body());
}
}
public final class SmsChannel implements NotificationChannel {
private final SmsGateway gateway;
public SmsChannel(SmsGateway gateway) { this.gateway = gateway; }
@Override
public void send(Recipient to, Message m) {
gateway.send(to.phoneNumber(), m.shortText());
}
}
public final class PushChannel implements NotificationChannel {
private final PushService push;
public PushChannel(PushService push) { this.push = push; }
@Override
public void send(Recipient to, Message m) {
push.deliver(to.deviceTokens(), m.title(), m.body());
}
}
public final class Notifier {
private final List<NotificationChannel> channels;
public Notifier(List<NotificationChannel> channels) {
this.channels = List.copyOf(channels);
}
public void notify(Recipient r, Message m) {
for (NotificationChannel channel : channels) {
channel.send(r, m); // subtype polymorphism — dispatches to the actual class
}
}
}

Read Notifier.notify and ask: what does it know about email, SMS, or push? Nothing. The loop calls send on the abstract type, and the JVM looks up the actual implementation on each object. Add a SlackChannel next quarter, and Notifier doesn’t move.

Now the parametric form. A Repository that works for any entity type:

public interface Repository<T, ID> {
Optional<T> findById(ID id);
void save(T entity);
void delete(ID id);
}
public final class InMemoryRepository<T, ID> implements Repository<T, ID> {
private final Map<ID, T> store = new HashMap<>();
private final Function<T, ID> idOf;
public InMemoryRepository(Function<T, ID> idOf) { this.idOf = idOf; }
@Override public Optional<T> findById(ID id) { return Optional.ofNullable(store.get(id)); }
@Override public void save(T entity) { store.put(idOf.apply(entity), entity); }
@Override public void delete(ID id) { store.remove(id); }
}

One repository implementation handles Order, Customer, Product, and anything else — the type parameter T is substituted at the call site. The two forms compose: a Repository<Order, OrderId> is parametric over its entity type and polymorphic across in-memory, SQL, and DynamoDB-backed implementations.

The anti-pattern polymorphism replaces — the one the reviewer is hoping you’ll spot:

// before — a dispatch chain hidden in a method
public void send(Recipient to, Message m, ChannelType type) {
if (type == ChannelType.EMAIL) {
smtp.send(to.emailAddress(), m.subject(), m.body());
} else if (type == ChannelType.SMS) {
gateway.send(to.phoneNumber(), m.shortText());
} else if (type == ChannelType.PUSH) {
push.deliver(to.deviceTokens(), m.title(), m.body());
} else {
throw new IllegalArgumentException("Unknown channel: " + type);
}
}

Three things are wrong here. The class now depends on every gateway concretely. Adding a SlackChannel requires editing this method (and finding every other method shaped like it). The else branch admits we cannot prove the dispatch is total, and a runtime exception is the cost. Replace the chain with subtype polymorphism, and all three problems vanish at once.

Variants and trade-offs#

Each form of polymorphism has a clear home; mixing them up costs design clarity.

Subtype polymorphism — open hierarchies. Use when the set of variants is open — the system should accept new variants written by other modules, other teams, or even later versions of the same module. PaymentMethod, NotificationChannel, Vehicle in a parking lot. The cost: dispatch happens at runtime; the compiler cannot warn you about a missing case because there is no “all the cases” to enumerate.

Parametric polymorphism — type-agnostic algorithms. Use when the algorithm is the same regardless of the element type. Collections.sort(List<T>) sorts any comparable list. List<T> itself stores any element. The cost: parametric code cannot reach into T for anything beyond what the bounds promise — <T extends Comparable<T>> lets you call compareTo; without a bound, you have only Object.

Ad-hoc polymorphism — overloading. Use when two operations are genuinely the same verb on different argument shapes. Math.max(int, int) and Math.max(double, double) are overloads; they share a name because they share a meaning. The cost: the compiler picks at the static type of the argument, not the runtime one — which sometimes surprises callers who expect overloading to dispatch like subtype polymorphism (it doesn’t).

A. Closed sum — the instanceof is honest. A Shape hierarchy with exactly three subclasses, none of which will ever be added to, and a method that computes a fact one of them can’t naturally provide. A switch on the type with an exhaustiveness check is sometimes the cleanest answer — the Visitor pattern is the principled name for this shape.

B. Open hierarchy — polymorphism wins. A Vehicle hierarchy that other teams can extend, with methods every subclass can sensibly answer. Subtype dispatch is the right move; an instanceof chain here is the anti-pattern, because the chain cannot be kept honest as new subclasses arrive.

The trade-off worth naming: polymorphic dispatch is slightly slower than a direct call — one virtual table lookup per method. In application code this is invisible; in a hot inner loop you sometimes care. Modern JVMs inline monomorphic calls aggressively, so the cost only shows up when the call site genuinely sees multiple concrete types. Not a reason to avoid polymorphism in 99% of code; worth knowing when the 1% comes up.

A second trade-off: subtype polymorphism couples the variants to a shared interface. Adding a new method to the interface forces every implementation to move. This is why interfaces should be narrow — the fewer methods the interface promises, the less the variants are coupled to each other. (This is the Interface Segregation Principle, said in polymorphism’s vocabulary.)

A third: parametric polymorphism erases the type parameter at runtime in Java. List<String> and List<Integer> are the same class once the bytecode is generated; you cannot ask a List<?> what its element type is. Most of the time this is fine; when you need the type at runtime, you pass a Class<T> explicitly.

When this is asked in interviews#

Polymorphism rarely arrives as a direct definitional question. It arrives as the answer to one of three setups.

The “add a new variant” follow-up. “Now suppose we have a SlackChannel too.” If your design routes new variants through editing existing methods, the reviewer downgrades. If a new variant is a new class plus a wiring change at the composition root, the reviewer moves on. The reviewer is checking whether polymorphism is doing the work, or whether the dispatch is still hiding in a switch.

The instanceof smell. Whenever you write if (x instanceof T) on the whiteboard, expect the reviewer to ask “could that be a method on x instead?” Almost always the answer is yes. The principled rewrite is to add an abstract method to the parent type and override it in each subclass; the instanceof collapses into a virtual call.

The “open or closed?” question. “Will new payment methods be added later?” is asking whether the variant set is open. If yes, subtype polymorphism. If the set is closed and small — Shape with three subclasses, Token with five tokens — a sealed hierarchy plus pattern-matching (or the Visitor pattern) can be the cleaner answer; the compiler can then enforce exhaustiveness. Recognising which kind of variation you have is the signal the reviewer is looking for.

The OCP cross-reference. “How does your design honour Open-Closed?” The clean answer points at the polymorphic seam: “the NotificationChannel interface; new channels add a class, old code doesn’t move.” If you cannot point at the seam, you have not earned the principle yet — and the reviewer will help you find one.

The strongest signal you can send on this pillar: every variant in your design lives in its own class, no method anywhere asks “which kind is this?”, and a new variant is one new file. That is polymorphism, the Open-Closed Principle, and the Strategy pattern all telling the same story.

Polymorphism is the pillar that pays out the most directly in pattern vocabulary — most of the Gang-of-Four behavioural patterns are polymorphism with a particular shape.

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.