Multiple Dispatch: Julia’s Different Answer to the Expression Problem
Sealed interfaces close the type list. Julia opens it, and the operation list, at once.
A recent piece on this site walked through how sealed interfaces and exhaustive switch expressions give Java a clean answer to the expression problem: lock down the set of types, and the compiler flags every place a new operation still needs a case. That solves half of the puzzle Philip Wadler described back in 1998. The other half, adding new types without touching old code, is exactly what sealed interfaces give up in exchange. Julia takes the opposite bet. Instead of closing the type list and opening the operation list, it opens both, by changing what a function is allowed to look at when it decides which code to run. That mechanism is called multiple dispatch, and it deserves a look on its own terms, not just as “the thing Julia does instead of classes.”
The Two Axes Wadler Was Pointing At
In a note sent to a mailing list about adding generics to Java, Wadler framed extensibility as a table. Rows are the cases of a data type, columns are the operations defined over them. Functional languages make it easy to add a column, a new function that pattern matches over a fixed set of cases, but painful to add a row. Object-oriented languages make it easy to add a row, a new subclass, but painful to add a column, since every existing class then needs a new method. Wadler’s challenge was whether a language could let programmers add rows and columns freely, without recompiling code that already works. His original note is still online, and it is worth reading in full: The Expression Problem, Philip Wadler, 1998.
Single Dispatch Only Asks One Question
Java, C#, Python and most mainstream object-oriented languages resolve a call by looking at exactly one thing: the runtime type of the receiver, the object to the left of the dot. That is single dispatch, and it is the reason the Visitor pattern exists at all. When behavior genuinely depends on two objects at once, say how an asteroid should react to colliding with a spaceship, single dispatch cannot express that directly. Visitor works around the limit with a double dispatch trick: the first call picks a method based on the receiver, and a second call inside that method picks again based on the argument. It works, but every new operation still means touching every visitable class, and every new class still means touching every visitor.
Julia Dispatches on Every Argument
Julia removes the workaround by letting the language look at every argument’s type, not only the first one. A function is really a generic function with a table of methods attached to it, and calling it selects whichever method’s signature is the most specific match for the actual argument types supplied, however many there are. Here is enough to see the shape of it:
abstract type Entity end struct Asteroid <: Entity end struct Spaceship <: Entity end interact(a::Asteroid, b::Asteroid) = "the rocks drift past each other" interact(a::Asteroid, b::Spaceship) = "the ship takes damage" interact(a::Spaceship, b::Spaceship) = "the ships dock safely" println(interact(Asteroid(), Spaceship()))
Notice what did not happen. There is no Asteroid class with a visit method, no Spaceship class with an accept method, and no third file wiring them together. Adding a new kind of Entity later means writing new interact methods for the combinations that matter, nothing else changes.
| Approach | Adding a new type | Adding a new operation |
|---|---|---|
| Sealed interfaces + switch | Compiler forces every existing switch to add a case | Add a method, nothing else changes |
| Classic Visitor pattern | Every Visitor implementation needs a new visit method | Add a new Visitor implementation, nothing else changes |
| Julia multiple dispatch | Add methods for the combinations that matter, nothing else changes | Add a new generic function, nothing else changes |
The Idea Predates Julia by Thirty Years
Multiple dispatch is not a Julia invention. Common Lisp’s Object System merged two earlier Lisp extensions, Symbolics’ Flavors and Xerox PARC’s CommonLoops, into generic functions that dispatch on every argument, standardized as part of ANSI Common Lisp in the late 1980s. Dylan and Cecil carried the idea through the 1990s, each refining how ambiguous matches get resolved. What Julia changed was the audience: it paired multiple dispatch with a type system built for numerical computing and a compiler that specializes each method body for the concrete types it sees, so the flexibility of a dynamic language stopped requiring the performance cost usually attached to one, as its creators describe in Fast Flexible Function Dispatch in Julia and in the language’s own SIAM Review introduction.
Does Checking Every Argument Cost Speed?
Looking at more than one argument type on every call sounds like it should be slower than a single virtual-method lookup. Julia’s own published microbenchmarks, which run identical algorithms across languages, suggest the concern does not hold up once the compiler specializes the call.

Specialization Is Doing the Real Work
The reason dispatch does not show up as overhead is that Julia resolves it once per distinct set of argument types, then compiles a specialized version of the method body through LLVM, the same way a C++ template gets instantiated per type, except automatically and at run time rather than by hand. Bezanson, Bolewski and Chen describe this directly: the cost of picking a method is paid once, not on every call, because the specialized, compiled code is cached and reused for that exact combination of types.
Generic Arithmetic, Not Just Generic Loops
A tight recursive loop is one kind of test. A more realistic one is a workload built from small, generic operations, addition, multiplication, comparisons, all dispatched through a language’s own operator system rather than hand-optimized machine code.

What Multiple Dispatch Gives Up
None of this is free. Sealed interfaces close the set of types on purpose, so the compiler can prove a switch handles every case. Miss one and the build fails before anyone ships it. Julia’s method tables are open by design, any package can add a method to any function at any time, which is exactly what makes the two-axis extensibility possible. The trade is that nothing checks exhaustiveness for you. Call interact with a combination nobody wrote a method for, and the program compiles fine, then throws a MethodError the first time that combination actually runs.
There is a second cost specific to multiple dispatch: ambiguity. If two methods are each more specific than the other in a different argument position, Julia will not silently guess, it raises a MethodError and asks the programmer to add a method that resolves the tie explicitly. CLOS and Dylan handle the same situation differently, falling back to a left-to-right argument order instead of refusing to choose. Neither answer is wrong. They are different opinions about whether a language should ever guess on your behalf.
| Sealed interfaces + switch (Java) | Multiple dispatch (Julia) | |
|---|---|---|
| Type set | Closed, fixed at compile time | Open, extendable by any package |
| Missing case | Compile error | Runtime MethodError |
| Ambiguous match | Not applicable | Runtime MethodError, resolved by hand |
| New operation | One new method, nothing else changes | One new generic function, nothing else changes |
Reach for multiple dispatch when
- The operation’s meaning genuinely depends on two or more argument types at once, not only the receiver
- New types will keep arriving from code you do not control, such as separate packages or plugins
- Losing compile-time exhaustiveness checking is an acceptable trade for open-ended extension
- The hot path can be specialized once per type combination rather than re-checked on every call
What We Learned
Sealed interfaces and Julia’s multiple dispatch answer the same 1998 question from opposite directions. One closes the type list so the compiler can guarantee every operation covers every case, then leaves the operation list open. The other opens both lists at once, by letting every argument’s type take part in dispatch instead of just the receiver’s, and pays for that openness with the loss of a compiler-checked exhaustiveness guarantee. Neither is a strictly better answer to the expression problem. They encode different bets about whether a closed type set or an open one matters more for a given codebase, and Julia’s own benchmark data suggests the flexibility does not have to cost the performance most engineers assume it would.

