Skip to content
Protocols, Multimethods & Ad-hoc Polymorphism

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")  // hello

Key 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 instancesextend-protocol / extend-type
Context boundsN/A (no type params at runtime)
Multiple dispatch on all argsdefmulti / defmethod

Scala 2 → Scala 3 → Clojure Syntax

FeatureScala 2Scala 3Clojure
Instanceimplicit valgivenextend-protocol
Parameter(implicit ev: Show[A])(using ev: Show[A])Just call it
Resolveimplicitly[Show[A]]summon[Show[A]]N/A
Enrichmentimplicit classextension methodsProtocols
DispatchCompile-timeCompile-timeRuntime

When to Use Which

NeedClojure Tool
Single-type polymorphismProtocol
Multi-arg dispatchMultimethod
Simple, non-extensibleJust 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 for IPersistentList, IPersistentVector, etc.
  • Retroactive extension: Both Scala and Clojure support this, but Clojure’s extend-protocol works on any JVM class — even java.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.