Protocols, Multimethods & Ad-hoc Polymorphism
Scala 3 introduced a clean given/using syntax for type classes — a pattern for ad-hoc polymorphism where types opt into behaviour without inheritance. Clojure has no built-in type class system, but it offers two powerful alternatives that achieve the same goal through runtime dispatch: protocols and multimethods.
This page compares the approaches and serves as a reference for translating type-class thinking into idiomatic Clojure.
Quick Ref: Scala 3 Type Classes
// 1. Define the type class
trait Show[A]:
def show(a: A): String
object Show:
given Show[Int] with
def show(a: Int): String = a.toString
given Show[String] with
def show(a: String): String = a
// 2. Consumer using `using`
def printIt[A](a: A)(using s: Show[A]): Unit =
println(s.show(a))
// 3. Use it — compiler resolves the instance
printIt(42) // 42
printIt("hello") // helloKey idea: trait (interface) + given instances (implementations) + using parameters (consumers) — all resolved at compile time.
Clojure Alternative 1: Protocols (Single-dispatch)
Protocols are the closest equivalent to type classes. Define a protocol, then extend it to existing types retroactively — just like type class instances.
;; Define the "type class"
(defprotocol Show
(show [this] "Display a value as a string"))
;; Extend to existing types — retroactive!
(extend-protocol Show
String
(show [s] s)
Number
(show [n] (str n))
clojure.lang.IPersistentVector
(show [v] (str "[" (clojure.string/join ", " (map show v)) "]")))
;; Use it — dispatch on the first argument's type
(show "hello") ;; => "hello"
(show 42) ;; => "42"
(show [1 2 3]) ;; => "[1, 2, 3]"Protocol limitation: dispatch only on the first argument’s type. For binary operations like equality:
(defprotocol Eq
(eq? [a b] "Generic equality"))
(extend-protocol Eq
String
(eq? [a b] (= a b))
Number
(eq? [a b] (= a b)))This works for (eq? 1 1) but breaks on (eq? 1 "1") — only a’s type is checked. For symmetric operations, reach for multimethods.
Clojure Alternative 2: Multimethods (Multiple Dispatch)
Dispatch on any function of all arguments — more powerful, slightly more overhead.
;; Define a dispatch function — decides which implementation
(defmulti semigroup-combine
(fn [x y] [(class x) (class y)]))
;; Define implementations
(defmethod semigroup-combine [java.lang.Long java.lang.Long] [x y]
(+ x y))
(defmethod semigroup-combine [java.lang.String java.lang.String] [x y]
(str x y))
(defmethod semigroup-combine :default [x y]
(throw (ex-info "No semigroup instance" {:x x :y y})))
;; Use it
(semigroup-combine 3 5) ;; => 8
(semigroup-combine "hello " "world") ;; => "hello world"For type classes with type parameters (like Functor), dispatch on the container type:
(defprotocol Functor
(fmap [f coll] "Map over structure"))
(extend-protocol Functor
clojure.lang.IPersistentList
(fmap [f coll] (map f coll))
clojure.lang.IPersistentVector
(fmap [f coll] (mapv f coll))
java.util.Optional
(fmap [f opt] (.map opt f)))
(fmap inc [1 2 3]) ;; => [2 3 4]
(fmap inc '(1 2 3)) ;; => (2 3 4)Clojure Alternative 3: Just Use Functions (Idiomatic for Simple Cases)
Clojure often skips the abstraction entirely and passes behaviour as data:
(defn show [v]
(cond
(string? v) v
(number? v) (str v)
(coll? v) (str "[" (clojure.string/join ", " (map show v)) "]")
:else (str v)))Trade-off: simple and readable, but not extensible — new types require editing the function. Protocols and multimethods let anyone add instances later without touching existing code.
Comparison Tables
Scala to Clojure Translation
| Scala (Type Class) | Clojure Equivalent |
|---|---|
trait Show[A] | defprotocol Show |
given Show[Int] | (extend-protocol Show Number ...) |
def f[A: Show](a) | (show a) — dynamic dispatch |
| Retroactive instances | extend-protocol / extend-type |
| Context bounds | N/A (no type params at runtime) |
| Multiple dispatch on all args | defmulti / defmethod |
Scala 2 → Scala 3 → Clojure Syntax
| Feature | Scala 2 | Scala 3 | Clojure |
|---|---|---|---|
| Instance | implicit val | given | extend-protocol |
| Parameter | (implicit ev: Show[A]) | (using ev: Show[A]) | Just call it |
| Resolve | implicitly[Show[A]] | summon[Show[A]] | N/A |
| Enrichment | implicit class | extension methods | Protocols |
| Dispatch | Compile-time | Compile-time | Runtime |
When to Use Which
| Need | Clojure Tool |
|---|---|
| Single-type polymorphism | Protocol |
| Multi-arg dispatch | Multimethod |
| Simple, non-extensible | Just a function |
| Dispatch on non-type criteria (value, metadata) | Multimethod |
Key Differences
- Compile-time vs Runtime: Scala resolves type classes at compile time; Clojure resolves protocols/multimethods at runtime. This makes Clojure more flexible (redefine instances on the fly) but loses compile-time guarantees — the static vs dynamic typing trade-off.
- Type parameters: Scala type classes naturally handle parameterised types (
Functor[F[_]]). Clojure protocols dispatch on the concrete class, so you write separate implementations forIPersistentList,IPersistentVector, etc. - Retroactive extension: Both Scala and Clojure support this, but Clojure’s
extend-protocolworks on any JVM class — evenjava.util.Optional— without wrapping.
Summary
Most common pattern in real Clojure code: protocols for single-dispatch polymorphism, multimethods when you need dispatch on multiple arguments or non-type criteria (value ranges, metadata), and plain functions for everything else. None of these are as strict as Haskell-style type classes, but they give you the same ad-hoc polymorphism in a dynamic setting — arguably with less ceremony.