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
| Kind | Question it answers | Example |
|---|---|---|
| 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:
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.
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 types — f : 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.
Which solves which problem
| Problem | Best fit |
|---|---|
| Generic container / pipe | Parametric |
| Equality, ordering, "show me" | Ad-hoc (type classes / protocols) |
| Hierarchical "is-a" with shared state | Subtype (rare in FP — usually a smell) |
| "Extra fields are fine" data | Row |
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
| Kind | Where you've seen it |
|---|---|
| Parametric | Any List<T> / Vec<T> / Map<K, V> in Java, C#, Rust, Go generics, TypeScript — the entire collections library, plus Option, Result, Future |
| Ad-hoc | Haskell type classes (Eq, Ord, Show, Num); Rust traits; Scala givens; Clojure protocols dispatching IPersistentCollection operations across all collection types |
| Ad-hoc + dispatch on multiple args | CLOS multimethods, Clojure multimethods (defmulti), Julia's whole language is built on this |
| Subtype | Java/C#/Swift class hierarchies, TypeScript structural subtyping (every React component prop type), Scala case-class hierarchies |
| Row | PureScript records, OCaml object types, Elm record-update syntax; in practice every "open map" pattern in Clojure / Python's **kwargs is row-polymorphic |
| Combinations | Rust = 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:
- Clojure's
(+ 1 2)and(+ 1.0 2.0)both work. (defn pair [a b] [a b])works on any two things.- A Java
List<T>accepts aDogwhere aList<Animal>is wanted (it doesn't — write down why this is rejected, and which polymorphism kind is tripping up here). (defn full-name [{:keys [first last]}] ...)works on any map with:firstand:last, however many other keys it has.