Types & Multiple Dispatch
Define types, write typed functions, and overload operators
Most languages use single dispatch — when you call a.add(b), the language looks at the type of a to decide which implementation to run. The type of b doesn't matter.
Equana uses multiple dispatch. When you call add(a, b), the runtime examines the types of all arguments and picks the most specific matching implementation. This is a natural fit for numerical computing where operations between different types are the norm.
This tutorial covers how to define types, write typed functions, overload operators, build type hierarchies, and why this design was chosen over traditional OOP.
Why Not OOP?
Object-oriented programming attaches methods to classes. To add two vectors you'd write a.add(b) — the method lives on a's class, and the language dispatches based on a's type alone. This creates several problems for numerical computing:
The asymmetry problem. a + b is symmetric, but a.add(b) isn't. Who "owns" the method when a is a scalar and b is a vector? In OOP you need workarounds like Python's __radd__, double dispatch, or visitor patterns.
The expression problem. Adding a new type that interacts with existing types requires modifying those existing classes — or using multiple inheritance, mixins, and adapter patterns that grow in complexity.
The ceremony problem. Scientific code is about data and operations on data. Wrapping every concept in a class with constructors, inheritance, and access modifiers adds boilerplate without adding clarity.
Multiple dispatch solves all three:
| Single Dispatch (OOP) | Multiple Dispatch (Equana) | |
|---|---|---|
| Method lookup | Based on receiver type only | Based on all argument types |
scalar * vector | Needs __rmul__ or visitor | Just works — dispatches on (Number, Vec) |
| Adding new type combos | Modify existing classes | Add a new function variant |
| Syntax | a.add(b) | a + b (operator) or add(a, b) |
| Inheritance | Required for polymorphism | Optional — abstract type hierarchies when you need them |
Key insight: In numerical computing, the operation matters more than which object "owns" it.
2 * vshouldn't belong to the number2or to the vectorv— it belongs to themulfunction, parameterized by the types(Number, Vec).
Defining Types
Structs
A struct defines a named type with typed fields. Instances are created by calling the struct name as a constructor:
struct Vec2 {
x: Float64
y: Float64
}
# Positional construction
v1 = Vec2(3.0, 4.0)
# Named-argument construction
v2 = Vec2(x=1.0, y=2.0)
println(v1)
println(v2)Struct constructors validate types at construction time — passing a String where a Float64 is expected produces a clear error.
Abstract Types
An abstract type declares a type that cannot be instantiated but can serve as a parent for structs and other abstract types. This creates type hierarchies for dispatch:
abstract Shape
struct Circle is Shape {
radius: Float64
}
struct Rectangle is Shape {
width: Float64
height: Float64
}
# Dispatch on the abstract parent type
function r = describe(s: Shape) {
r = "some shape"
}
# More specific variant wins when available
function r = describe(c: Circle) {
r = "circle with radius " + string(c.radius)
}
println(describe(Circle(5.0)))
println(describe(Rectangle(3.0, 4.0)))The Circle variant is more specific than the Shape variant, so describe(Circle(5.0)) dispatches to the circle-specific method. But describe(Rectangle(3.0, 4.0)) has no rectangle-specific variant, so it falls back to the Shape method.
Abstract types can also form deeper hierarchies:
abstract Shape
abstract Polygon is Shape
struct Triangle is Polygon {
a: Float64
b: Float64
c: Float64
}
struct Square is Polygon {
side: Float64
}
# Matches any Shape
function r = kind(s: Shape) { r = "shape" }
# Matches any Polygon (more specific than Shape)
function r = kind(p: Polygon) { r = "polygon" }
# Matches only Square (most specific)
function r = kind(s: Square) { r = "square" }
println(kind(Triangle(3.0, 4.0, 5.0)))
println(kind(Square(2.0)))Type Annotations on Functions
Add type annotations to function parameters using name: Type syntax. When multiple variants of the same function exist, Equana builds a method table and dispatches to the best match at call time:
# Define three variants of the same function
function r = describe(x) { r = "something else" }
function r = describe(x: Number) { r = "a number" }
function r = describe(x: String) { r = "a string" }
# The runtime picks the most specific match
println(describe(42))
println(describe("hello"))
println(describe(true))The untyped variant describe(x) acts as a fallback — it matches any argument but has the lowest priority. When a typed variant matches, it always wins.
Arrow functions support type annotations too, for concise dispatch variants:
classify = x -> "unknown"
classify = (x: Int64) -> "an integer"
classify = (x: Float64) -> "a float"
println(classify(42))
println(classify(3.14))
println(classify(true))Multi-argument dispatch
The dispatch engine selects the variant based on both argument count and argument types:
struct Point {
x: Float64
y: Float64
}
# One-argument variant: distance from origin
function d = distance(p: Point) {
d = sqrt(p.x^2 + p.y^2)
}
# Two-argument variant: distance between two points
function d = distance(a: Point, b: Point) {
d = sqrt((a.x - b.x)^2 + (a.y - b.y)^2)
}
p1 = Point(3.0, 4.0)
p2 = Point(6.0, 8.0)
println(distance(p1))
println(distance(p1, p2))No overload resolution annotations or special syntax needed — just define the variants you want.
Operator Overloading
Every operator in Equana maps to a named function. When you write a + b, the runtime calls add(a, b) and dispatches based on argument types. If no user-defined variant matches, it falls back to the built-in implementation.
| Operator | Function | Operator | Function | |
|---|---|---|---|---|
+ | add | == | eq | |
- | sub | != | neq | |
* | mul | < | lt | |
/ | div | > | gt | |
^ | pow | <= | le | |
% | mod | >= | ge | |
unary - | neg | ! | not |
To overload + for a custom type, define a function named add with typed parameters:
struct Vec2 {
x: Float64
y: Float64
}
# Overload + for Vec2
function r = add(a: Vec2, b: Vec2) {
r = Vec2(a.x + b.x, a.y + b.y)
}
v1 = Vec2(1.0, 2.0)
v2 = Vec2(3.0, 4.0)
# This dispatches to our add(Vec2, Vec2) function
v3 = v1 + v2
println(v3)
# Built-in + still works for numbers
println(3 + 4)Where multi-dispatch shines
Consider scalar-vector multiplication. In OOP, you'd need to decide: does Number have a method that knows about Vec2, or does Vec2 have a method that knows about Number? Neither is clean.
With multi-dispatch, you simply define a function for the type pair (Number, Vec2):
struct Vec2 {
x: Float64
y: Float64
}
# scalar * vector
function r = mul(a: Number, b: Vec2) {
r = Vec2(a * b.x, a * b.y)
}
v = Vec2(3.0, 4.0)
r = 2 * v
println(r)The dispatch engine matches 2 * v to the signature mul(Number, Vec2) by examining both arguments. No special reverse-method protocol needed.
You can define as many type combinations as you need — mul(Vec2, Number), mul(Vec2, Vec2) (component-wise), etc. — and the dispatcher picks the right one.
Unary operators and comparison
Unary - maps to neg, and == maps to eq:
struct Vec2 {
x: Float64
y: Float64
}
# Negate a vector
function r = neg(v: Vec2) {
r = Vec2(-v.x, -v.y)
}
# Equality check
function r = eq(a: Vec2, b: Vec2) {
r = (a.x == b.x) && (a.y == b.y)
}
v = Vec2(3.0, -4.0)
negated = -v
println(negated)
# Equality
v1 = Vec2(1.0, 2.0)
v2 = Vec2(1.0, 2.0)
v3 = Vec2(1.0, 3.0)
println(v1 == v2)
println(v1 == v3)Custom Infix Operators
Any binary function can be turned into an infix operator using the @infix(n) decorator, where n is the precedence (higher binds tighter):
struct Vec3 {
x: Float64
y: Float64
z: Float64
}
@infix(25)
function r = cross(a: Vec3, b: Vec3) {
r = Vec3(
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
)
}
@infix(25)
function r = dot(a: Vec3, b: Vec3) {
r = a.x * b.x + a.y * b.y + a.z * b.z
}
u = Vec3(1.0, 0.0, 0.0)
v = Vec3(0.0, 1.0, 0.0)
# Use as infix — reads like math notation
println(u cross v)
println(u dot v)The precedence value determines how tightly the operator binds relative to other operators. For reference, + has precedence 10 and * has precedence 20.
Function Aliases
The alias keyword creates a new name for an existing function. The alias inherits all dispatch variants:
function r = double(x) { r = x * 2 }
alias twice = double
println(twice(5))
println(twice(3.14))Dispatch Specificity
When multiple variants match a call, the dispatcher picks the most specific one. The specificity hierarchy from most to least specific:
- Concrete struct types —
Vec2,Circle(created viastruct) - Abstract types —
Shape,Number(created viaabstract) - Untyped / Any — parameters with no type annotation
A struct subtype is always preferred over its parent abstract type:
abstract Shape
struct Circle is Shape {
radius: Float64
}
function r = area(s: Shape) { r = 0.0 }
function r = area(c: Circle) { r = 3.14159 * c.radius^2 }
println(area(Circle(5.0)))If no variant matches the argument types at all, the runtime throws a clear error:
# Only accepts Int64
strict = (x: Int64) -> x * 2
# This works
println(strict(5))
# This would error: No matching method for 'strict' with arguments (String)
# strict("hello")Namespaces
Namespaces group related types and functions together. Use export to make members accessible from outside:
namespace Geometry {
export struct Point {
x: Float64
y: Float64
}
export function d = distance(a: Point, b: Point) {
d = sqrt((a.x - b.x)^2 + (a.y - b.y)^2)
}
}
p1 = Geometry.Point(0.0, 0.0)
p2 = Geometry.Point(3.0, 4.0)
println(Geometry.distance(p1, p2))Higher-Order Functions and Lambdas
Functions are first-class values. Arrow syntax -> creates anonymous functions, and they participate in dispatch just like named functions:
# Arrow functions
double = x -> x * 2
add_pair = (a, b) -> a + b
println(double(21))
println(add_pair(3, 4))
# Pass functions to higher-order functions
arr = [1, 2, 3, 4, 5]
println(map(arr, x -> x^2))
println(filter(arr, x -> x > 2))
println(reduce(arr, (a, b) -> a + b, 0))Gradual Typing
You don't have to type everything. Untyped functions continue to work exactly as before — the type system is opt-in. You can start with untyped code and add types incrementally where they provide value:
# Start with a single untyped function
function r = identify(x) { r = "something" }
# Later, add typed variants for specific behavior
function r = identify(x: Int64) { r = "an integer" }
function r = identify(x: String) { r = "a string" }
# Typed variants win when they match; untyped is the fallback
println(identify(42))
println(identify("hi"))
println(identify(true))The untyped variant becomes a fallback that handles any type not covered by a typed variant. This lets you progressively add type safety without rewriting existing code.
Summary
Key Concepts
- Multiple dispatch — function selection based on all argument types, not just one
- Structs — nominal types with typed fields and constructor validation
- Abstract types — type hierarchies for dispatch (
abstract Shape,struct Circle is Shape) - Operator overloading — define
add,mul,neg, etc. for custom types - Custom infix —
@infix(n)turns any binary function into an operator - Aliases —
alias twice = doublecreates alternate names - Specificity — concrete struct > abstract type > untyped
- Gradual typing — opt-in; untyped code works alongside typed code
Why Not OOP?
- Numerical operations are symmetric —
a + bshouldn't "belong" toa - Multi-dispatch handles mixed-type operations naturally (
Number * Vec2) - No inheritance, no method resolution order, no diamond problem
- Functions are first-class — define new type combinations without touching existing code
Operator → Function Mapping
| Operator | Function | Operator | Function |
|---|---|---|---|
+ | add | == | eq |
- | sub | != | neq |
* | mul | < | lt |
/ | div | > | gt |
^ | pow | <= | le |
% | mod | >= | ge |
unary - | neg | ! | not |
Further Reading
- Browse the complete function reference for detailed documentation
- Experiment in the notebook with your own types and dispatch