Julia Module 6 – The type system and multiple dispatch
Methods and multiple dispatch
Add methods to Julia functions and predict which one a call runs, resolve ambiguities, extend Base without type piracy, and write generic code that composes.
What you will learn
- Add methods to a generic function and predict which one a call runs
- Resolve a method ambiguity by defining the method for the overlap
- Extend Base functions for your own types without type piracy
- Write one generic algorithm that runs on Float64, BigFloat and Rational inputs
Before you start
What you’ll learn · Before you start
What you will learn
- Add methods to a generic function and predict which one a call runs
- Resolve a method ambiguity by defining the method for the overlap
- Extend Base functions for your own types without type piracy
- Write one generic algorithm that runs on Float64, BigFloat and Rational inputs
Before you start
On this page
+ alone has 155 methods in Julia 1.13.1, and 1 + 2.0, 1//2 + 1//3 and [1, 2] + [3, 4] each run a different
one. Which one runs is decided at the moment of the call, from the types of all the arguments, and that single rule
lets a solver written for floating-point numbers run unchanged on exact fractions.
This lesson adds methods to functions and predicts which one a call runs, resolves the ambiguities that two methods can create, extends Base for your own types without changing other people’s code, and writes a generic algorithm that runs on five kinds of number. The runs use Julia 1.13.1.
A function is a set of methods, and a call runs one of them
Heat leaves a wall in three ways: by conduction through a layer, by convection into the air and by radiation from the
surface. Each has its own formula, so heat_flux gets one method: One definition of a function for some types of arguments. A Julia function can have many methods, and each call runs the most specific one that fits the argument types. More in “What Julia is for, and when to choose it” per kind of path, and a
vector holds all three:
# Three ways heat leaves a wall, and one method of heat_flux for each.
abstract type HeatPath end
struct Conduction <: HeatPath # through a layer: k·A·ΔT / d
k::Float64 # conductivity, W/(m·K)
area::Float64 # m²
thickness::Float64 # m
end
struct Convection <: HeatPath # into moving air: h·A·ΔT
h::Float64 # W/(m²·K)
area::Float64
end
struct Radiation <: HeatPath # from the surface: ε·σ·A·(T₁⁴ − T₂⁴)
emissivity::Float64
area::Float64
end
const σ = 5.670374419e-8 # Stefan–Boltzmann constant, W/(m²·K⁴)
heat_flux(p::Conduction, hot, cold) = p.k * p.area * (hot - cold) / p.thickness
heat_flux(p::Convection, hot, cold) = p.h * p.area * (hot - cold)
heat_flux(p::Radiation, hot, cold) = p.emissivity * σ * p.area * (hot^4 - cold^4)
paths = HeatPath[Conduction(0.8, 2.0, 0.2), Convection(10.0, 2.0), Radiation(0.9, 2.0)]
for p in paths # each call picks the method for the type of p
println(rpad(string(nameof(typeof(p))), 11), round(heat_flux(p, 350.0, 300.0); digits = 1), " W")
end
println("total: ", round(sum(p -> heat_flux(p, 350.0, 300.0), paths); digits = 1), " W")
println(methods(heat_flux))
try
heat_flux("brick", 350.0, 300.0)
catch err
println(first(split(sprint(showerror, err), '\n')))
end from session import files, run
# One function, three methods, called on a vector that holds all three kinds of path.
files("heat_transfer.jl")
run("""
julia heat_transfer.jl
""") Output
$ julia heat_transfer.jl
Conduction 400.0 W
Convection 1000.0 W
Radiation 704.9 W
total: 2104.9 W
# 3 methods for generic function "heat_flux" from Main:
[1] heat_flux(p::Radiation, hot, cold)
@ ~/demo/heat_transfer.jl:20
[2] heat_flux(p::Convection, hot, cold)
@ ~/demo/heat_transfer.jl:19
[3] heat_flux(p::Conduction, hot, cold)
@ ~/demo/heat_transfer.jl:18
MethodError: no method matching heat_flux(::String, ::Float64, ::Float64)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 heat_transfer_session.py
heat_flux is one generic function: A Julia function seen as the set of all its methods, such as + with its 155 methods. A call runs one of them, chosen from the types of all the arguments. with 3 methods, and methods(heat_flux)
listed them. The loop has no if on the type of p: each call ran the method for the path it was given, 400.0 W
through a 0.2 m layer, 1,000.0 W into the air and 704.9 W by radiation from a surface at 350 K, with σ, the
Stefan–Boltzmann constant, taken from its CODATA value. A call that no method accepts,
heat_flux("brick", 350.0, 300.0), stopped with a MethodError that names the argument types it was given. Step through how one call finds its
method:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The call. The loop calls heat_flux(p, 350.0, 300.0) with p taken from a Vector{HeatPath}. The vector's element type says only that p is some HeatPath.
- Every argument counts. At this call p is a Convection, and the temperatures are Float64. Julia looks at the types of all three arguments, not only the first.
- Which methods fit. Of the 3 methods of heat_flux, only heat_flux(p::Convection, hot, cold) accepts a Convection as its first argument.
- The most specific. When several methods fit, the most specific one is chosen. Here one fits, so it is the one.
- It runs. Its code, compiled for these argument types, computes 10.0 × 2.0 × 50.0 = 1000.0 W, as the run printed for Convection.
- No method fits. heat_flux("brick", 350.0, 300.0) has no method for a String, so the call stopped with MethodError: no method matching heat_flux(::String, ::Float64, ::Float64).
- Two fit equally. In ambiguity.jl both combine methods fit combine(1, 2) and neither is more specific, so Julia refused to guess: MethodError: combine(::Int64, ::Int64) is ambiguous.
A new kind of path needs a new type and a method, and nothing that already exists changes: the loop, sum and every
other caller work with it at once.
Dispatch looks at the types of every argument
The methods of heat_flux differ only in their first argument. The manual calls choosing a method from all of a
function’s arguments multiple dispatch: Choosing which method of a function to run from the number and the types of all its arguments, not only the first, as Julia does on every call. More in “What Julia is for, and when to choose it”, and distance between points and circles
needs it, because the formula depends on both shapes:
# distance(a, b) between points and circles: one method for each pair of argument types.
abstract type Shape end
struct Point <: Shape
x::Float64
y::Float64
end
struct Circle <: Shape
center::Point
r::Float64
end
distance(p::Point, q::Point) = hypot(p.x - q.x, p.y - q.y)
distance(p::Point, c::Circle) = max(0.0, distance(p, c.center) - c.r)
distance(c::Circle, p::Point) = distance(p, c)
distance(a::Circle, b::Circle) = max(0.0, distance(a.center, b.center) - a.r - b.r)
o = Point(0, 0)
c = Circle(Point(3, 4), 2)
println("point to point: ", distance(o, Point(3, 4)))
println("point to circle: ", distance(o, c))
println("circle to point: ", distance(c, o))
println("circle to circle: ", distance(Circle(o, 1), c))
# Base's + works the same way: which method runs depends on both arguments.
for (code, types) in (("1.5 + 2.5", (Float64, Float64)), ("1 + 2.0", (Int, Float64)), ("1//2 + 1//3", (Rational{Int}, Rational{Int})),
("1 + 2im", (Int, Complex{Int})), ("[1, 2] + [3, 4]", (Vector{Int}, Vector{Int})))
m = which(+, types)
println(rpad(code, 17), first(split(sprint(show, m), " @")), " (", m.file, ")")
end
println("+ has ", length(methods(+)), " methods") from session import files, run
# Four methods of distance, and the methods of + that five calls pick.
files("distance.jl")
run("""
julia distance.jl
""") Output
$ julia distance.jl
point to point: 5.0
point to circle: 3.0
circle to point: 3.0
circle to circle: 2.0
1.5 + 2.5 +(x::T, y::T) where T<:Union{Float16, Float32, Float64} (float.jl)
1 + 2.0 +(x::Number, y::Number) (promotion.jl)
1//2 + 1//3 +(x::Rational, y::Rational) (rational.jl)
1 + 2im +(x::Real, z::Complex) (complex.jl)
[1, 2] + [3, 4] +(A::Array, Bs::Array...) (arraymath.jl)
+ has 155 methods
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 distance_session.py
Each pair of types has its own method, and the call picks by both: 5.0 from the origin to the point (3, 4), 3.0 to the circle of radius 2 around it, in either order, and 2.0 between that circle and a circle of radius 1 at the origin. In a language that dispatches only on the first argument, the second shape would need a test inside each method.
which shows the method a call would run, and + works the same way. Two Float64 values met a method for
floating-point types, an Int64 and a Float64 met the catch-all +(x::Number, y::Number) of promotion.jl, which
converts both and tries again, and two vectors met the method for arrays.
Branching on the type inside one method
Wrong: distance(a, b) = if a isa Point && b isa Circle … elseif …, one long method that every new shape must edit.
Right: one short method per pair of types, so a new shape adds methods and leaves the existing ones as they are.
Checkpoint
Check: A function as a set of methods · Dispatch on every argument
The most specific method wins
Methods may overlap: a method for Real also fits every Integer. The manual’s rule is that when several methods
fit, the most specific one applicable to the arguments runs. Three methods of kind, for three levels of the tree:
# When several methods fit a call, the most specific one runs.
kind(x::Number) = "a number"
kind(x::Real) = "a real number"
kind(x::Integer) = "an integer"
for x in (7, 2.5, 1 + 2im, true, big(10)^30, 0x0f)
println(rpad(repr(x), 34), kind(x))
end
# invoke runs a less specific method on purpose.
println("invoke(kind, Tuple{Real}, 7): ", invoke(kind, Tuple{Real}, 7))
println("invoke(kind, Tuple{Number}, 7): ", invoke(kind, Tuple{Number}, 7))
# Julia never converts an argument to make a method fit.
area(w::Float64, h::Float64) = w * h
try
area(2, 3.5)
catch err
println(first(split(sprint(showerror, err), '\n')))
end from session import files, run
# Three methods of kind, six values, and a method that does not fit at all.
files("specificity.jl")
run("""
julia specificity.jl
""") Output
$ julia specificity.jl
7 an integer
2.5 a real number
1 + 2im a number
true an integer
1000000000000000000000000000000 an integer
0x0f an integer
invoke(kind, Tuple{Real}, 7): a real number
invoke(kind, Tuple{Number}, 7): a number
MethodError: no method matching area(::Int64, ::Float64)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 specificity_session.py
7, true, big(10)^30 and 0x0f are all integers, so each ran the Integer method; 2.5 ran the Real method
and 1 + 2im the Number method. “More specific” follows the tree: Integer <: Real <: Number, so the method for
the narrower type wins whenever it fits. invoke runs a less specific method on purpose, which a method uses to call
the general version it extends, as invoke(kind, Tuple{Real}, 7) did.
The last call shows what dispatch does not do. area(w::Float64, h::Float64) did not accept area(2, 3.5): Julia
never converts an argument to make a method fit, as the manual stresses, so the Int64 2 found no method. Either
accept Real and let the arithmetic convert, or convert at the call.
Key idea
A call runs the most specific method that fits the types of all its arguments, and an argument is never converted to make a method fit.
When two methods fit equally well, Julia refuses to guess
Two methods can both fit a call without either being more specific. Here one accepts an integer first and anything
real second, the other the reverse, and combine(1, 2) fits both:
# Two methods that both fit a call, with neither more specific: an ambiguity.
module Mixing
combine(a::Integer, b::Real) = "integer first"
combine(a::Real, b::Integer) = "integer second"
end
using .Mixing: combine
println(combine(1, 2.5), " | ", combine(2.5, 1))
try
combine(1, 2)
catch err
println(sprint(showerror, err))
end
using Test
println("ambiguities found: ", length(Test.detect_ambiguities(Mixing)))
Mixing.eval(:(combine(a::Integer, b::Integer) = "both integers")) # the method for the overlap
println(combine(1, 2))
println("ambiguities found: ", length(Test.detect_ambiguities(Mixing))) from session import files, run
# An ambiguity, what Julia says about it, and the method that resolves it.
files("ambiguity.jl")
run("""
julia ambiguity.jl
""") Output
$ julia ambiguity.jl
integer first | integer second
MethodError: combine(::Int64, ::Int64) is ambiguous.
Candidates:
combine(a::Real, b::Integer)
@ Main.Mixing ~/demo/ambiguity.jl:4
combine(a::Integer, b::Real)
@ Main.Mixing ~/demo/ambiguity.jl:3
Possible fix, define
combine(::Integer, ::Integer)
ambiguities found: 1
both integers
ambiguities found: 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ambiguity_session.py
The manual says that in such a method ambiguity: A call that two or more methods fit equally well, with none more specific than the others. Julia throws a MethodError instead of choosing; defining the method for the overlap resolves it. Julia raises a MethodError rather
than arbitrarily picking a method, and the error lists both candidates and suggests the fix: define
combine(::Integer, ::Integer), the method for the overlap. Once it existed, combine(1, 2) returned “both
integers”. The manual recommends defining the method for the overlap first, before the two methods that create it.
Ambiguities hide until a call happens to land in the overlap, perhaps months later in someone else’s code.
Test.detect_ambiguities(Mixing) finds them without making any such call: it reported 1 for the module, and 0 after
the fix, so a test can assert that a package has none; Aqua.jl, a package of quality checks, runs the same test with
others.
Avoid ambiguities by design
Dispatch on one argument at a time where you can, or convert the arguments to one type first and keep a single
method for that type: the manual names both. Then put @test isempty(Test.detect_ambiguities(MyPackage)) in your
tests.
Checkpoint
Check: The most specific method wins · Method ambiguities and their fix
Extend Base for your own types, never for Base’s types
Adding a method to a Base function for your own type is how a type joins the rest of Julia. Interval below gets a
+ and a show, and sum works on a vector of intervals without being told anything about them:
# Adding methods to Base functions for your own type, and what type piracy does to other code.
struct Interval
lo::Float64
hi::Float64
end
Base.:+(a::Interval, b::Interval) = Interval(a.lo + b.lo, a.hi + b.hi)
Base.show(io::IO, x::Interval) = print(io, "[", x.lo, ", ", x.hi, "]")
legs = [Interval(2.0, 2.5), Interval(1.0, 1.25), Interval(3.0, 3.5)] # travel times in hours
println(legs[1] + legs[2])
println("sum(legs) = ", sum(legs)) # sum is generic: it needs only + for Interval
# Code that has never heard of the module below.
module Report
function label(parts)
try
return parts[1] + parts[2]
catch err
err isa MethodError || rethrow()
return "MethodError, as expected"
end
end
end
println("before: ", Report.label(["km", "/h"]))
module Shortcuts
# Type piracy: a Base function, for Base types, defined in a module that owns neither.
Base.:+(a::String, b::String) = a * b
end
println("after: ", Report.label(["km", "/h"])) from session import files, run
# A + method for an Interval type, then a pirated + for strings and what it does to unrelated code.
files("extend_base.jl")
run("""
julia extend_base.jl
""") Output
$ julia extend_base.jl [3.0, 3.75] sum(legs) = [6.0, 7.25] before: MethodError, as expected after: km/h
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 extend_base_session.py
Three legs of a journey, taking 2 to 2.5, 1 to 1.25 and 3 to 3.5 hours, added up to [6.0, 7.25]: sum is generic
code that only needs + for its elements. The second half of the run is the opposite case. The module Shortcuts
defined + for two Strings, and owns neither the function nor the type. The style guide calls this
type piracy: Adding a method to a function you do not own, such as Base's +, for argument types you do not own either. Every module shares the function, so the method changes code that never asked for it. and warns that it can introduce incompatibilities that are hard to predict.
Step through what it did to a module that never mentions Shortcuts:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The first call. Report.label computes "km" + "/h". + has no method for two Strings, so the call fails.
- A MethodError. Report catches the MethodError and prints "MethodError, as expected": joining strings is written with *, not +.
- The pirate. Shortcuts defines Base.:+(a::String, b::String). It owns neither the function + nor the type String: that is type piracy.
- The same call again. Report has not changed and never mentions Shortcuts, but the method table of + has: the same call now finds the pirated method.
- A different answer. The call returned "km/h" instead of an error. Every module that uses + sees the new method, which is why the style guide asks you to avoid type piracy.
Report.label was unchanged, yet it returned “km/h” instead of its MethodError, because methods belong to the
function and every module shares Base.:+. Aqua.jl also checks a package for obvious piracy. Who owns the function
and who owns the types decides each case:
| You define | For | Verdict |
|---|---|---|
Base.:+(a::Interval, b::Interval) |
your type | Extending Base: the way a type joins in |
Base.show(io::IO, x::Interval) |
your type | Extending Base |
Base.:+(a::String, b::String) |
Base’s types | Type piracy: changes everyone’s + |
mytotal(v::Vector{Float64}) |
Base’s types, your function | Fine: the function is yours, so no other code changes |
Checkpoint
Check: Extending Base without type piracy
One generic algorithm runs on number types it has never seen
Newton’s method improves a guess x for a root of f with x - f(x) / df(x). Written without a single type
annotation, the same function runs on whatever number it is given, and the arithmetic dispatches to that type’s
methods:
# One Newton solver for f(x) = 0, written without a single type, run on five kinds of number.
function newton(f, df, x; steps = 5)
for _ in 1:steps
x = x - f(x) / df(x)
end
return x
end
f(x) = x^2 - 2
df(x) = 2x
for x0 in (1.0f0, 1.0, big(1.0), 1//1, big(1)//1)
r = newton(f, df, x0)
println(rpad(string(typeof(r)), 17), "error ", Float64(abs(r - sqrt(big(2)))))
end
println("5 steps from 1//1: ", newton(f, df, 1//1))
try
newton(f, df, 1//1; steps = 6)
catch err
println(sprint(showerror, err))
end
println("6 steps from big(1)//1: ", newton(f, df, big(1)//1; steps = 6)) from session import files, run
# The same five Newton steps on Float32, Float64, BigFloat and two kinds of Rational.
files("generic_newton.jl")
run("""
julia generic_newton.jl
""") Output
$ julia generic_newton.jl
Float32 error 2.420323420895794e-8
Float64 error 9.667293313452913e-17
BigFloat error 8.992928321650453e-25
Rational{Int64} error 8.992928321650453e-25
Rational{BigInt} error 8.992928321650453e-25
5 steps from 1//1: 886731088897//627013566048
OverflowError: 886731088897 * 886731088897 overflowed for type Int64
6 steps from big(1)//1: 1572584048032918633353217//1111984844349868137938112
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 generic_newton_session.py
Each run came back in the type it started with, and each type set its own limit. Float32 stopped at an error of
2.4e-8 and Float64 at 9.7e-17, each as close to √2 as its type can get. BigFloat reached 9.0e-25 after the same five
steps and would keep improving. The Rational runs stayed exact, the fraction 886731088897//627013566048, but a
sixth step squared its numerator and stopped with OverflowError for Int64: Rational{Int64} checks its arithmetic
for overflow. With BigInt inside, Rational{BigInt} took the sixth step.
Nobody wrote newton for fractions. It works because -, /, ^ and * have methods for every one of these types,
and a type written later, such as the dual numbers of the lesson on custom number types, joins in by defining them.
Values as types multiply compiled code
Val(42) turns a value into a type, so a method can dispatch on it, as f(::Val{N}) where {N} does. The
performance tips warn against overusing this: every distinct value becomes a type of its own, every function that
receives it is compiled again for it, and too many such types waste compile time and memory.
Checkpoint
Check: Generic algorithms over number types
Practise: particles that interact
Symmetric methods without ambiguities
Define abstract type Particle end and the types Electron, Proton and Neutron, and a method
interaction(a::Particle, b::Particle) that returns “repel” or “attract” from the signs of their charges. Add
interaction(a::Neutron, b::Particle) returning “none”, then the method that makes
interaction(Proton(), Neutron()) give the same answer. Check on your machine that Test.detect_ambiguities(Main) names the overlap you
have just created, and that it finds nothing once you add the method for two neutrons.
The self-check below asks about methods, dispatch on every argument, specificity, ambiguities, piracy and generic code, starting where you are least sure; each answer is explained the moment you give it:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): how is multiple dispatch different from method overloading in Java or C++?
Overloading in Java or C++ chooses among functions of the same name at compile time, from the declared types of the
arguments, and virtual methods add a run-time choice on one object only, the receiver. Julia chooses the method on
every call, from the run-time types of all the arguments, and picks the most specific one that fits. So
distance(c, p) finds the method for a circle and a point even when both come out of a Vector{Shape}, where
overloading would see only the declared type Shape. The methods belong to the function, not to a class, so anyone
can add a method for their own types to an existing function, and generic code such as sum works with it at once.
Julia raises an error instead of guessing when two methods fit equally well.
Two design questions grow out of this lesson: two packages whose methods collide, and a shortcut that changes how everyone’s code behaves. For each, name the methods involved before you look at the first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
Two packages add methods to a shared function combine(a, b): one for (MyMatrix, AbstractMatrix), the other for (AbstractMatrix, TheirMatrix). Users who load both get "ambiguous" MethodErrors for some calls. How do you find every ambiguity, fix the one you have, and keep new ones from appearing?
-
Fresher to senior
A colleague adds Base.:+(v::Vector{Float64}, x::Float64) = v .+ x to a project's utilities so that v + 1.0 works. What is wrong with it, what can it break, and what would you do instead?
Key takeaways
- A generic function is a set of methods, and each call runs one of them, chosen from the types of all its arguments when the call happens.
- A vector of mixed types needs no type tests:
heat_fluxran the method for each path, and a new type adds methods without changing existing code. - The most specific method that fits wins; Julia never converts an argument to make a method fit, and
invokecalls a less specific method on purpose. - Two methods that fit a call equally well are an ambiguity, a
MethodError; define the method for the overlap and test withTest.detect_ambiguities. - Extend Base functions for your own types; defining them for types you do not own, type piracy, changed what an
unrelated module’s
+returned. - Generic code takes its arithmetic from its arguments: one
newtonran on five kinds of number, each with its own limit,Rational{Int64}with anOverflowErroron its sixth step.
Terms in this lesson
- generic function
- A Julia function seen as the set of all its methods, such as + with its 155 methods. A call runs one of them, chosen from the types of all the arguments.
- method ambiguity
- A call that two or more methods fit equally well, with none more specific than the others. Julia throws a MethodError instead of choosing; defining the method for the overlap resolves it.
- type piracy
- Adding a method to a function you do not own, such as Base's +, for argument types you do not own either. Every module shares the function, so the method changes code that never asked for it.
References
- Methods: defining methods, method ambiguities and parametric methods (The Julia Project)
- Style guide: avoid type piracy (The Julia Project)
- Unit testing: Test.detect_ambiguities (The Julia Project)
- Essentials: which, methods and invoke (The Julia Project)
- Performance tips: the dangers of abusing multiple dispatch (The Julia Project)
- Aqua.jl: automated quality assurance, including ambiguities and type piracy (JuliaTesting)
- CODATA value: Stefan–Boltzmann constant (National Institute of Standards and Technology)
Report a problem with this lesson
Kept only in this browser. Your Learn progress