~ / track E / deep theory

Polymorphism taxonomy

Advanced

"Polymorphism" is one word covering at least four distinct mechanisms. They solve overlapping problems but with different costs and constraints. The classical taxonomy is Strachey's (parametric vs ad-hoc), refined by Cardelli & Wegner to add subtype and inclusion polymorphism. Row polymorphism is the more recent addition.

The four kinds

KindQuestion it answersExample
Parametric"Works for any type, uniformly."id : ∀a. a → a, map : (a → b) → [a] → [b]
Ad-hoc"Works for each type, possibly differently."Operator overloading, type classes, multimethods
Subtype (inclusion)"Works on any subtype of T."Java interfaces, OO inheritance
Row"Works for any record containing these fields."OCaml objects, PureScript records, Clojure maps

Parametric: one implementation, all types

Parametric polymorphism means one function body works uniformly across types. The function can't inspect what type its argument is — it can only move it around. This is also called parametricity, and the consequence is free theorems: from a type alone you can derive properties.

reverse : ∀a. [a] → [a]

Just from this type, you can prove map f (reverse xs) = reverse (map f xs). The function literally cannot know what a is, so it must commute with map.

In Clojure, every higher-order seq function is parametric in this sense:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Ad-hoc: different behavior per type

Ad-hoc polymorphism gives different implementations for different types, chosen by the type system (or runtime). Examples:

  • C++ / Java overloading: compiler picks based on static types.
  • Haskell type classes: (+) :: Num a => a -> a -> a — the dictionary is passed implicitly, looked up by the type.
  • Clojure multimethods: dispatch by an arbitrary function on the arguments.
  • Clojure protocols: dispatch on the first argument's type, JVM-fast.
loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

The function call looks the same; the implementation chosen depends on the data.

Subtype: works on supertypes

Subtype polymorphism (inclusion) is the OO classic: if Dog <: Animal, then a function accepting Animal accepts any Dog. Java, C#, Scala, TypeScript lean on this heavily. It breaks principal typesf : Number → Number could secretly be Integer → Real, so inference becomes much harder.

Clojure has structural subtyping in a weak form: any object satisfying a protocol is "a" thing of that protocol's shape. But there's no nominal hierarchy on data.

Row polymorphism: works on shape extensions

A function that wants a record "with at least these fields" is row-polymorphic:

;; PureScript / OCaml-ish notation
draw : forall r. { x :: Int, y :: Int | r } -> Picture

The | r means "and possibly other fields." The function commits only to what it actually reads. Clojure's open-map convention is a duck-typed version: (defn draw [{:keys [x y]}] ...) works on any map containing :x and :y — extra keys flow through invisibly.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Which solves which problem

ProblemBest fit
Generic container / pipeParametric
Equality, ordering, "show me"Ad-hoc (type classes / protocols)
Hierarchical "is-a" with shared stateSubtype (rare in FP — usually a smell)
"Extra fields are fine" dataRow

A great deal of OO grief comes from using subtype polymorphism where parametric, ad-hoc, or row would have been cleaner.

Real-world: each kind in production

KindWhere you've seen it
ParametricAny List<T> / Vec<T> / Map<K, V> in Java, C#, Rust, Go generics, TypeScript — the entire collections library, plus Option, Result, Future
Ad-hocHaskell type classes (Eq, Ord, Show, Num); Rust traits; Scala givens; Clojure protocols dispatching IPersistentCollection operations across all collection types
Ad-hoc + dispatch on multiple argsCLOS multimethods, Clojure multimethods (defmulti), Julia's whole language is built on this
SubtypeJava/C#/Swift class hierarchies, TypeScript structural subtyping (every React component prop type), Scala case-class hierarchies
RowPureScript records, OCaml object types, Elm record-update syntax; in practice every "open map" pattern in Clojure / Python's **kwargs is row-polymorphic
CombinationsRust = parametric + ad-hoc (traits) + a little subtype (lifetime variance). Haskell = parametric + ad-hoc + higher-kinded. Scala = all four

In Clojure specifically, protocols (ad-hoc on first arg, fast), multimethods (ad-hoc on any function, flexible), and open maps with keyword keys (row-polymorphic by default) are the three workhorses — and together they cover most real-world polymorphic needs without ever needing a type hierarchy.

Check yourself

? quiz

A function `length : ∀a. [a] → Int`. What does parametricity guarantee?

Exercise

For each, name the polymorphism kind:

  1. Clojure's (+ 1 2) and (+ 1.0 2.0) both work.
  2. (defn pair [a b] [a b]) works on any two things.
  3. A Java List<T> accepts a Dog where a List<Animal> is wanted (it doesn't — write down why this is rejected, and which polymorphism kind is tripping up here).
  4. (defn full-name [{:keys [first last]}] ...) works on any map with :first and :last, however many other keys it has.
 status: new