A self-contained illustration of the Holy trait pattern applied to a Geometry contract: a library that lets users add their own shapes without editing the library, and without forcing every shape into one inheritance branch.

The library needs one mandatory piece from every shape — signed_distance — and can optionally use a faster analytic normal if a shape provides one, else fall back to computing it numerically. Plain multiple dispatch can express “call the right method for this type,” but it can’t express “and if that method doesn’t exist, call this other one instead” — dispatch either finds a method or errors. A trait closes that gap: a plain function, NormalTrait(::Type{<:Geometry}), maps each concrete type onto a tag type (HasAnalyticNormal or NoAnalyticNormal), and the library dispatches on the tag instead of on the shape. Selecting a fallback becomes ordinary multiple dispatch again, at one remove.

using InteractiveUtils, LinearAlgebra, StaticArrays

abstract type Geometry end

signed_distance(g::Geometry, x) = error("$(typeof(g)) must implement signed_distance(g, x)")
signed_distance (generic function with 1 method)

signed_distance is the entire mandatory contract: negative inside the shape, positive outside, zero on the boundary. The fallback body just documents what a new subtype must supply — it’s never meant to run.

struct Sphere <: Geometry
    center::SVector{2,Float64}
    radius::Float64
end

signed_distance(g::Sphere, x) = norm(x - g.center) - g.radius
signed_distance (generic function with 2 methods)

Sphere fulfils the contract and nothing else. It doesn’t know it needs a normal method yet — and it doesn’t have to, because normal is optional.

abstract type NormalTrait end
struct HasAnalyticNormal <: NormalTrait end
struct NoAnalyticNormal <: NormalTrait end

NormalTrait(::Type{<:Geometry}) = NoAnalyticNormal()  # the library's default: assume none

normal(g::Geometry, x) = normal(NormalTrait(typeof(g)), g, x)

function normal(::NoAnalyticNormal, g::Geometry, x)
    h = 1e-6
    dx, dy = SVector(h, 0.0), SVector(0.0, h)
    grad = SVector(
        (signed_distance(g, x + dx) - signed_distance(g, x - dx)) / 2h,
        (signed_distance(g, x + dy) - signed_distance(g, x - dy)) / 2h,
    )
    return grad / norm(grad)
end
normal (generic function with 2 methods)

normal(g, x) never mentions Sphere. It looks up NormalTrait(typeof(g)), gets the catch-all NoAnalyticNormal(), and dispatch on that singleton picks the finite-difference method above — a numeric fallback built entirely out of signed_distance, so it works for any Geometry at all, today or added next year.

x0 = SVector(3.0, 4.0)
s = Sphere(SVector(0.0, 0.0), 5.0)
normal(s, x0)
2-element SVector{2, Float64} with indices SOneTo(2):
 0.5999999999289457
 0.8000000000532907

x0 sits exactly on the unit sphere’s boundary at (0.6, 0.8), so the numeric fallback should recover that direction from signed_distance alone — and it does, up to the finite-difference step size. It works, but it’s the slow path: four extra signed_distance evaluations per call.

A dynamic check — if applicable(analytic_normal, g, x) ... else ... end — could pick a fast path when one exists too. The difference is when that decision is made. applicable asks a question at runtime, every call, and the compiler can’t see the answer in advance. NormalTrait(typeof(g)) asks a question the compiler can already answer once typeof(g) is known, because it’s just another method lookup — so the branch below resolves to a single static call, with the unchosen method never even compiled in. The trait doesn’t replace dispatch; it is dispatch, aimed at a question dispatch couldn’t otherwise see.

NormalTrait(::Type{Sphere}) = HasAnalyticNormal()
analytic_normal(g::Sphere, x) = (x - g.center) / norm(x - g.center)

normal(::HasAnalyticNormal, g::Geometry, x) = analytic_normal(g, x)

normal(s, x0)
2-element SVector{2, Float64} with indices SOneTo(2):
 0.6
 0.8

Opting Sphere in took two one-line methods, added after the fact, next to Sphere rather than inside normal. Nothing about normal’s definition changed, and nothing that already called normal(s, x0) needed to change either — it now silently runs the fast path.

A third, independent shape can join the same contract, fully-fledged from the start:

struct Plane <: Geometry
    point::SVector{2,Float64}
    outward::SVector{2,Float64}  # must be unit length
end

signed_distance(g::Plane, x) = dot(x - g.point, g.outward)
NormalTrait(::Type{Plane}) = HasAnalyticNormal()
analytic_normal(g::Plane, x) = g.outward

normal(Plane(SVector(0.0, 0.0), SVector(0.0, 1.0)), SVector(1.0, 0.3))
2-element SVector{2, Float64} with indices SOneTo(2):
 0.0
 1.0

Plane never touches Sphere, and the library file that defines Geometry, signed_distance, and normal never mentions Plane or Sphere at all. That’s the payoff: the contract is a set of free-standing methods a user attaches to their own type, and multiple dispatch — indexed on the trait tag rather than the concrete type — assembles them into the right behaviour automatically.

Confirming it’s actually zero-cost

NormalTrait(typeof(g)) looks like a runtime call. Lowered IR shows it’s literally there as one:

println("normal(g, x), lowered:")
display(@code_lowered normal(s, x0))
normal(g, x), lowered:
CodeInfo(
1 ─ %1 = Main.normal
 %2 = Main.NormalTrait
 %3 = Main.typeof
 %4 =   dynamic (%3)(g)
 %5 =   dynamic (%2)(%4)
 %6 =   dynamic (%1)(%5, g, x)
└──      return %6
)

Two calls, exactly as written: NormalTrait(typeof(g)), then normal(<result>, g, x). Nothing here has resolved anything yet — lowering doesn’t know types. The collapse happens one stage later:

io = IOBuffer()
show(io, (@code_typed normal(s, x0)))
typed_ir = String(take!(io))
println(typed_ir)
CodeInfo(
1 ─ %1 =    invoke Main.analytic_normal(g::Sphere, x::SVector{2, Float64})::SVector{2, Float64}
└──      return %1
) => SVector{2, Float64}

With typeof(s) === Sphere known, NormalTrait(typeof(g)) constant-folds to the singleton HasAnalyticNormal() at compile time, which makes normal(::HasAnalyticNormal, g, x) the only possible method — so the typed IR calls analytic_normal directly. NoAnalyticNormal’s method body, and the branch that would have chosen it, aren’t in this function at all: the compiler specialises normal per concrete Geometry type, the same way it specialises everything else, and a trait dispatch is just an argument to specialise on.

The takeaway

The contract is signed_distance, mandatory, plus normal, optional and defaulted. Julia has no syntax for “optional method” or “default implementation” the way an interface with default methods might — traits build both out of ordinary dispatch: a function from type to tag type stands in for the missing “does this type implement X?” query, resolved at compile time, so the fallback costs nothing when it isn’t taken and the opt-in costs nothing extra when it is.