Multiple dispatch in a dynamically-typed language
How Equana picks which function to run — on argument count and runtime types, walking a type hierarchy — and why operators, user types, and built-ins all share one dispatch table.
In most languages you learn, a + b is a fixed thing. It's a syntactic operator with hardwired rules, and if you want it to work on your own type you implement a magic method with a reserved name — __add__, operator+, Addable. In Equana, a + b is not special at all. It desugars to add(a, b), a completely ordinary function call, and add is resolved by the same machinery that resolves every other call in the language: multiple dispatch on the runtime types of all the arguments.
That single decision — no privileged operators, one dispatch table for everything — is what lets a user-defined Vec2 add itself with + in three lines, with no interface to implement and no operator to overload. This post is about how that dispatch actually works: what it keys on, how it walks the type hierarchy, how ties break, and where the sharp edges are.
What "dispatch" is choosing between
Equana is a dynamically-typed AST-walking interpreter — there's no JIT and no bytecode, the tree-walker just evaluates nodes. A function name in Equana is not one function. It's a bucket of methods, each with its own parameter types. The registry is a DispatchTable, and internally it's exactly what that sounds like: a Map<string, MethodDef[]> — one name, many method definitions.
function r = area(s: Circle) { r = pi * s.radius^2 }
function r = area(s: Rectangle) { r = s.width * s.height }
Both statements register a method under the name area. When you call area(shape), the interpreter doesn't look up "the" area function — it looks up the list of all area methods and selects one based on what shape actually is at runtime. Nothing about Circle or Rectangle needed to know about area in advance; the methods attach to the name, not to the type.
This is the inversion that trips up people coming from OOP. In single-dispatch, method resolution is object-oriented: shape.area() finds area by looking inside shape's class (or up its inheritance chain). The receiver — the thing before the dot — is privileged; it alone decides which method runs. Every other argument is just data along for the ride. Equana has no privileged argument. area(s), s.area(), and s area are three spellings of the same dispatched call, and the method is chosen by looking at every argument at once.
The resolution algorithm
When resolve(name, args) runs, it does three things:
- Filter by arity and type. Keep only the methods whose parameter count equals the argument count, and where each argument's runtime type is a subtype of the corresponding parameter's declared type. An untyped parameter matches anything.
- Rank the survivors by specificity. If more than one method matches, find the single most specific one.
- Decide. Exactly one winner returns it. Zero matches returns null (which surfaces as a
MethodError: No matching method for 'area' with arguments (String)). A genuine tie that isn't resolvable throws an ambiguity error.
Arity comes first and it's absolute: add(a, b) and add(a, b, c) are unrelated buckets that never compete. Dispatch is on argument count and argument types, in that order.
The type test is isSubtype(argType, constraint), and the interesting word there is subtype, because Equana types form a hierarchy.
Walking the type hierarchy
Every value has a runtime type tag, and every tag has an ancestor chain. These are the real chains from the interpreter:
Float64 → AbstractFloat → Real → Number → Any
Int64 → Signed → Integer → Real → Number → Any
Complex128 → Complex → Number → Any
String → AbstractString → Any
A method whose parameter is typed Number matches a Float64 argument because Number appears in Float64's chain. This is what makes abstract types useful: you write one add(a: Number, b: Number) and it accepts every concrete numeric type, while still leaving room for a more specific method to win when one exists.
Specificity is defined directly in terms of position in that chain. A constraint that sits lower in the hierarchy (closer to the concrete type) is more specific; Any is the least specific thing there is, and an untyped parameter scores zero. So given:
function r = describe(x: Number) { r = "some number" }
function r = describe(x: Float64) { r = "a float specifically" }
describe(3.0) # → "a float specifically"
describe(3) # → "some number"
describe(3.0) matches both methods — Float64 is a subtype of Number — but Float64 is a more specific constraint than Number for a Float64 argument, so the concrete method wins. describe(3) only matches the Number method, because Int64 is not a subtype of Float64.
When two methods each win on different argument positions — neither is uniformly more specific — that's a real ambiguity, and Equana throws a MethodError rather than picking arbitrarily. This is the same failure mode Julia has, and it's a feature: an ambiguous call is a design bug in your method set, and you want to hear about it at the call site, not to silently get whichever method happened to be defined first.
There is one deliberate tie-breaker. When two methods are equally specific, the later-registered one wins. That's what lets your own definitions override built-ins: the standard library registers add(a: Number, b: Number) at startup, and if you later define your own add at the same specificity, yours takes precedence.
Operators are just names
Here's the desugaring, verbatim from the evaluator's operator map:
+ → add - → sub * → mul / → div
^ → pow % → mod == → eq != → neq
< → lt > → gt <= → le >= → ge
evalBinaryExpr does almost nothing: it evaluates the left and right operands, looks up the function name for the operator, and makes an ordinary dispatched call. (&& and || are the two exceptions — they short-circuit, so they can't be plain calls.) Unary - is neg, ! is not, postfix ' is transpose.
The consequence is that operator overloading isn't a feature Equana has — it's a thing that falls out of not treating operators specially. To make + work on a new type, you define add for that type. There is no separate mechanism.
A worked example: giving a new type arithmetic
Define a 2D vector as a struct, then teach add about it:
struct Vec2 {
x: Float64,
y: Float64
}
function r = add(a: Vec2, b: Vec2) {
r = Vec2(a.x + b.x, a.y + b.y)
}
u = Vec2(1.0, 2.0)
v = Vec2(3.0, 4.0)
w = u + v # dispatches to add(a: Vec2, b: Vec2)
println(w.x) # 4.0
println(w.y) # 6.0
4.0
6.0
When you declare a struct, its name is registered as a new type tag in the hierarchy (with Any as its ancestor, or a named parent if you write struct Circle is Shape { ... }). From that moment Vec2 is a first-class participant in dispatch, indistinguishable from a built-in as far as resolve is concerned. Your add(a: Vec2, b: Vec2) method lands in the exact same add bucket as the standard library's add(a: Float64, b: Float64) and add(a: Complex128, b: Complex128). When the parser desugars u + v into add(u, v), the dispatcher filters the whole bucket, sees that only the Vec2 method matches two Vec2 arguments, and calls it.
You never registered Vec2 with add. You never implemented an Addable interface. The method and the type are independent facts that the dispatcher joins at call time. That's the property functional programmers call solving the expression problem from both sides: you can add a new type and give it existing operations, or add a new operation across existing types, without editing either.
Where this comes from, and where the edges are
The design is openly Julia-inspired — Julia made multiple dispatch the organizing principle of a whole numerical ecosystem, and it's the right model for math, where A * B means genuinely different algorithms for dense matrices, sparse matrices, scalars, and vectors, and no single argument "owns" the operation. Equana takes the same core idea and runs it in a dynamically-typed interpreter in the browser.
The trade-offs are real and worth naming as engineering facts:
- Dispatch has a runtime cost. Every call filters a method list and ranks candidates. In a hot numerical loop this matters — which is exactly why the actual number-crunching doesn't live in the interpreter. Dispatch selects a kernel; the kernel is a WebAssembly routine that runs over shared memory. The interpreter is orchestration, and it can afford to be simple.
- Ambiguities are your problem to resolve. The dispatcher will refuse to guess. Define a more specific method to break a genuine tie.
- Specificity is a total-ish order, not magic. It's the position of a constraint in a linear ancestor chain. It handles the common cases cleanly; deeply overlapping abstract types can still produce ambiguities you have to design around.
For me the payoff is conceptual economy. There is one rule — the most specific method whose parameter types match the arguments wins — and it explains operators, math on built-in numeric types, method-call syntax, and user-defined types all at once. Nothing is a special case. 2 + 2, A * B on two matrices, and u + v on your own vector type are the same mechanism.
You can try every snippet here at equana.dev (alpha, free, sign-in for the full notebook). If you want the parser side of the story — how u + v, u add v, and u.add(v) all reach the same call — I wrote that up in two-pass parser for optional syntax, and the philosophy behind the readable-syntax layer is in designing a language that reads like English.