skip to content

GRASP's Polymorphism principle says to replace type-based conditionals with polymorphic operations. In which situations is a plain conditional on type still the better design choice?

level: middleimportance: should knowfreq 38%

answer

  1. one branch site → keep the switch
  2. don't own the type → visitor / extension / table
  3. boundary parse is a legitimate conditional
  4. closed set + growing ops → sealed + exhaustive match
  5. only constants differ → data table

basics

~20 s

Keep the conditional when there is only one place that branches, when the variants come from code you can't subclass, when you're at a parsing boundary turning data into objects, or when the set of variants is closed and you want the compiler to check you handled all of them.

solid answer

~50 s

Polymorphism is a trade, not a law. Prefer a conditional when: (1) branching happens in exactly one place — a hierarchy adds files without removing duplication; (2) you don't own the types (third-party or generated classes you cannot subclass), so an external dispatch table or Visitor is the only option; (3) you're at the boundary translating untyped input into objects — that mapping is inherently a conditional and must exist somewhere; (4) the variant set is closed and you want exhaustiveness checking — sealed types with pattern matching give compile-time proof that all cases are handled, which a subclass hierarchy does not give for *new operations*; (5) the behavior belongs to the caller, not the variant — putting HTTP formatting inside a domain class to avoid a switch trades a small conditional for a coupling violation; (6) variants differ only by constants, where a data table beats both. Also: branching on *values or states* rather than *types* is not what the principle addresses.

code

text · 12 lines
text
// Legitimate conditional #1 — boundary parse, exactly one site
method = registry[record.method] ?: throw UnknownMethod(record.method)

// Legitimate conditional #2 — closed set, compiler-checked exhaustiveness,
// operation lives in the presentation layer where it belongs
sealed interface Shape { Circle | Square | Rect }

fun renderSvg(s: Shape) = when (s) {   // compiler errors if a variant is added
    is Circle -> "<circle .../>"
    is Square -> "<rect .../>"
    is Rect   -> "<rect .../>"
}   // note: Shape stays free of SVG knowledge

go deeper

for a junior

Name two exceptions: a single branch site isn't worth a hierarchy, and something must still map input data to the right class.

for a middle

Add ownership of the types, the behavior-belongs-elsewhere case, and note that branching on values or state is not what the principle covers.

for a senior

Bring in sealed types with compiler-checked exhaustiveness and frame open vs closed variant sets via the expression problem; mention Visitor and extension mechanisms for types you don't own.

for a principal

Discuss it as a codebase policy: which families are open extension points, which are closed ADTs, who owns each, and how the choice constrains team boundaries and release coupling.

## 1. First, the framing GRASP Polymorphism is a *responsibility assignment* heuristic, not a prohibition on `if`. The frequent junior over-correction — "conditionals are a code smell, always subclass" — produces hierarchies with one method, dozens of near-empty classes, and logic scattered so thinly that no one can review it. Knowing the exceptions is what separates applying a rule from doing design. The underlying question is always: **does this branch cost me change amplification?** If adding a variant means editing many places, polymorphism pays. If it means editing one obvious place, it usually doesn't. ## 2. The legitimate exceptions ### (a) A single branch site One switch, in one function, with short cases, is *more* readable than a hierarchy — all alternatives are visible side by side, which makes reviewing them for consistency trivial. The refactoring's payoff scales with the number of duplicated switches; at n=1 the payoff is zero and the cost (files, indirection, navigation) is real. Wait for the second or third occurrence. ### (b) You don't own the types If the variants are classes from a third-party library, generated from a schema (protobuf, OpenAPI, database entities), or belong to another team's module, you cannot add methods to them. Options: an external lookup table keyed by the type, a **Visitor** if the owner cooperates by providing an `accept` hook, or — in languages with extension methods/protocols/type classes (Kotlin extensions, Swift protocol extensions, Rust traits, Haskell type classes, Scala given instances) — attach behavior externally, which recovers polymorphic dispatch without owning the type. Absent those, a well-isolated conditional is honest. ### (c) The construction / parsing boundary Data arriving as JSON, a database discriminator column, a CLI flag, or a message header is untyped. **Something must map code → class**, and that something is a conditional or a registry lookup. This is not a smell; it is the border crossing. The rule is: exactly one such site per variant family, and it should produce objects so nothing downstream branches again. ### (d) A closed variant set where you want exhaustiveness With `sealed`/`enum`/algebraic data types (Kotlin sealed classes, Java sealed interfaces, Rust enums, TypeScript discriminated unions, Scala ADTs), a `when`/`match` over the variants can be **checked by the compiler**: add a variant and every non-exhaustive match fails to compile. This gives you the safety property people think subclassing gives them, while *keeping all the alternatives visible in one place* and — crucially — making it cheap to add new **operations** without touching the variant classes. When the variant set is genuinely fixed by the domain (currencies of a payment processor you support, HTTP methods, chess piece kinds) and operations keep growing, exhaustive matching is the better half of the expression problem. ### (e) The behavior doesn't belong to the variant Avoiding a switch by moving `renderHtml()`, `toSqlInsert()`, or `logForAudit()` into domain classes couples the domain to a delivery mechanism and destroys cohesion. GRASP itself resolves this: **High Cohesion** and **Low Coupling** outrank a mechanical application of Polymorphism. Better answers here: a separate polymorphic *renderer* hierarchy (Strategy), a Visitor, or an isolated switch inside the presentation layer. ### (f) Variants differ only by data If `Gold`, `Silver`, `Bronze` tiers differ only in a discount rate and a shipping threshold, three subclasses are three ceremonies around two numbers. A configuration table or a parameterized value object is more extensible than either the switch or the hierarchy, because new tiers require no code deployment at all. ### (g) Performance-critical inner loops Rarely decisive, but real: dynamic dispatch inhibits inlining and can defeat branch prediction and vectorization. In tight numerical or per-packet code, a monomorphic branch or a jump table may be measurably faster. Only act on measurement, never on assumption. ### (h) It isn't type-varying behavior at all Branching on a boolean flag, a numeric range, a feature toggle, a null check, or a validation outcome is **not** what the principle addresses. Turning `if (amount > 1000)` into a class hierarchy is a misreading. Related: branching on an object's *state* is the **State** pattern's territory, and branching to choose an *algorithm* is **Strategy** — both use polymorphism, but the trigger and the resulting shape differ from "subtype the domain entity". ## 3. A decision checklist 1. Is the branch on a **type** (not a value, flag, or state)? If no → not this principle. 2. Does the same discriminator drive **two or more** operations? If no → keep the conditional. 3. Do I **own** the types? If no → external dispatch, Visitor, or extension mechanism. 4. Is the variant set **open** (third parties add variants) or **closed** (fixed by the domain)? Open → hierarchy/registry. Closed with growing operations → sealed types + exhaustive match. 5. Does the behavior **belong** to the variant? If no → Strategy/Visitor in the right layer, not a method on the entity. 6. Do the variants differ only in **data**? If yes → table-driven. ## 4. Hybrid designs are normal Mature systems mix approaches: a sealed hierarchy for the closed core, a registry for the open plugin set, an exhaustive match in one module for a cross-cutting operation that would otherwise pollute every variant class, and a data table for the parts that are purely numeric. Being able to justify *why each part is what it is* is the actual senior skill; uniform application of one rule is not.

  • Sealed types with exhaustive matching keep the switch. Doesn't that contradict GRASP Polymorphism?
    It satisfies the principle's *goal* — no silent missed case, no unchecked change amplification — by a different mechanism. The compiler enforces completeness across every match site, so adding a variant is a compile error rather than a runtime surprise. It deliberately picks the other side of the expression problem: cheap new operations, controlled cost for new types.
  • How do you add type-varying behavior to a class you cannot modify?
    Options, roughly in order of preference: an extension mechanism if the language has one (extension functions, protocol extensions, traits, type classes); a Visitor if the owner exposes an accept hook; an adapter/wrapper hierarchy you do own; or an external dispatch map keyed by the type, isolated in one place.
  • What is the difference between the State pattern and simply subtyping the entity?
    State models behavior that varies over an object's *lifetime* — the object delegates to a state object and swaps it as it transitions. Subtyping the entity fixes the behavior at construction. If an order can move from Draft to Paid to Shipped, subclassing `Order` is wrong because you cannot change an object's class; a delegated state object is the fit.

Hiring a specialist per case makes sense when the same decision recurs in many departments. If exactly one clerk, once a day, sorts mail into three bins, hiring three specialists is worse than the clerk's checklist.

saying these in an interview costs you the question

  • "Every `if` on a type is a smell that must be refactored" — the payoff only appears when the same discriminator drives multiple sites.
  • Treating value/range/flag conditionals as the target of this principle.
  • Adding `render`, `toJson`, or `saveToDb` methods to domain classes purely to eliminate a switch, trading a conditional for a layering violation.
  • Claiming polymorphism gives compile-time exhaustiveness — it guarantees each subtype implements each declared operation, not that every *call site* handled every subtype (that's sealed types + matching).
  • Ignoring that some variants can't be subclassed at all because you don't own them.
  • Modelling lifecycle stages as subclasses, which cannot change after construction.

context