Multiple Dispatch as an Extension Point: Designing Julia Libraries Users Can Extend
Multiple dispatch lets a Julia library expose an extension point that downstream packages fill in without forking. Here is the pattern, the macros that verify it, and the compile-latency cost.
19 Jul 2026, 07:51 UTC

The problem with a closed operation set
Suppose you publish a small Julia package with one entry point:
function distance(kind::Symbol, x, y)
if kind === :euclidean
return norm(x - y)
elseif kind === :manhattan
return sum(abs.(x .- y))
else
error("unknown metric: $kind")
end
end
Every new metric means editing your package and cutting a release. A user who needs cosine distance can fork, wrap, or file an issue and wait. The wrapper is the fragile option, because it cannot participate in your own generic algorithms.
Multiple dispatch offers a different seam. Instead of dispatching on a symbol, you dispatch on the type of the metric itself. The library declares an abstract type and a generic function; downstream packages add methods to that function for their own types. Nobody edits your source.
Designing the seam: an abstract type plus a generic function
Two declarations do the work. The abstract type marks the family; the generic function names the operation.
# MyMetrics.jl
abstract type Metric end
function distance end # declared, no methods yet
function pairwise_mean(m::Metric, xs, ys)
total = 0.0
for (x, y) in zip(xs, ys)
total += distance(m, x, y)
end
return total / length(xs)
end
pairwise_mean is written once and never changes. It calls distance, and the method that runs is chosen at call time from the runtime types of all three arguments: the metric and both data points. That is the whole mechanism. Julia picks the most specific applicable method across the full argument tuple, not just the first argument.
A downstream package then adds:
using LinearAlgebra, MyMetrics
struct Cosine <: Metric end
distance(::Cosine, x, y) = 1 - dot(x, y) / (norm(x) * norm(y))
No registration, no monkey-patching, no changes to MyMetrics. The new method is visible to pairwise_mean immediately, and it composes with anything else that accepts a Metric. This is the same pattern Tables.jl uses: the package defines an abstract interface (Tables.rows, Tables.schema), and downstream types implement it so one generic algorithm works on DataFrames, CSV rows, database cursors, and custom iterators alike.
Verifying that dispatch did what you think
Dispatch bugs are quiet. Several macros make them visible. Run these in the REPL or a script after loading both packages; they need no special permissions and do not modify state.
@which distance(Cosine(), [1.0, 0.0], [0.0, 1.0])should point at theCosinemethod, not a fallback. If it points somewhere unexpected, your signature is too broad or too narrow.@code_typed distance(Cosine(), [1.0, 0.0], [0.0, 1.0])shows the inferred return type. You wantFloat64, notAnyand notUnion{}. AnAnyreturn means inference gave up, which usually traces back to a type-unstable field or a non-concrete signature.@code_warntypehighlights the same instabilities in color.methods(distance)andlength(methods(distance))let you watch the method table grow as extensions load.
For the example above, the expected value is 1.0: orthogonal unit vectors have a zero dot product, so the cosine distance is 1 - 0. Treat that as a check on your reading of the formula, not as evidence that the code ran. Run it yourself.
The trade-off: compile latency and method-table growth
Dispatch is not free. The first call to a method compiles a specialization for that exact combination of argument types; later calls reuse it. The first call is therefore orders of magnitude slower than the second, and the gap widens as your method table grows and your types get more complex. Base's + carries hundreds of methods, and that is part of why startup and time-to-first-execution are recurring complaints in large Julia codebases.
Three practical mitigations:
- Precompile the hot path.
PrecompileTools.jllets a package declare a small workload that runs during precompilation, so users pay the compile cost once at install rather than every session. - Stop unnecessary specialization.
@nospecializeon an argument tells the compiler not to generate a separate method instance per concrete type. Use it on abstract arguments that never sit on a hot numeric path. - Avoid pathological hierarchies. Deep abstract trees and signatures that pin concrete parametric types (like
Vector{Float64}instead ofVector{T}) multiply specializations without buying speed.
Two more constraints matter before you commit to this design. Keyword arguments do not participate in dispatch: f(x; k=1) dispatches only on x, so if you need to dispatch on options, make them positional or wrap them in a type. And GPU kernels in CUDA.jl or AMDGPU.jl forbid dynamic dispatch inside the kernel, so host-side dispatch works normally while kernel code must stay type-stable and monomorphic.
Version note: union-splitting heuristics changed between Julia 1.6 and 1.10, and Julia 1.10 changed world-age semantics for eval-based code loading. If your package generates methods at runtime, pin the Julia version in CI and test with --compiled-modules=no to surface invalidation problems.
When to reach for dispatch, and when not to
Multiple dispatch is the right seam when the extension axis is a type or an operation that downstream packages will add. It is not a universal replacement for object-oriented design. Stateful objects with private fields are usually better modeled as Base.@kwdef structs with accessor functions, or as closures that capture state, because dispatch has no notion of encapsulation.
A concrete check before you publish: can a downstream package add a new behavior without editing your source or recompiling your module? If yes, you have a real extension point. If the answer requires a fork, the switch statement is still in there somewhere. Verify with @which on a method you defined in a separate file, and confirm the method table grew by the amount you expected.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.