Julia Module 6 – The type system and multiple dispatch
Parametric types and generic numeric code
Define parametric Julia structs with promoting constructors, tie argument types with where clauses, keep data in its own number type, and drop abstract fields.
What you will learn
- Define parametric structs, with an outer constructor that promotes mixed inputs
- Write methods with where clauses that tie argument types together
- Write numeric code that keeps Float32, BigFloat and Rational inputs in their own type
- Replace abstract field types with type parameters, and measure the allocations saved
Before you start
What you’ll learn · Before you start
What you will learn
- Define parametric structs, with an outer constructor that promotes mixed inputs
- Write methods with where clauses that tie argument types together
- Write numeric code that keeps Float32, BigFloat and Rational inputs in their own type
- Replace abstract field types with type parameters, and measure the allocations saved
Before you start
On this page
A struct whose fields are declared AbstractVector and Real made one pass over a million numbers allocate
95,991,840 bytes. The same struct with type parameters allocated 0, for the same data and the same loop. And a mean
that starts its sum at 0.0 turned Float32 data into Float64 and exact fractions into rounded ones.
This lesson defines structs with type parameters and constructors that promote mixed inputs, writes methods with
where clauses, keeps numeric code in the type of its data, removes abstract field types, and puts a length into a
type with NTuple. The runs use Julia 1.13.1.
A type parameter turns one definition into a family of types
struct Point{T<:Real} declares a type parameter: A type written in braces after a type's name, such as T in Point{T}, that the type's fields or a method's arguments refer to. Each value of T gives another type of the family, such as Point{Float64}. T, and both fields have that
type. The definition makes one parametric type: A type defined with type parameters, such as struct Point{T<:Real}. It stands for a whole family of types, one concrete member for each parameter, and alone it is a UnionAll type., Point, and a concrete type for
every real T:
# One definition, a family of types: Point{T} for every real type T.
struct Point{T<:Real}
x::T
y::T
end
println(Point(1, 2), " ", Point(1.5, 2.0), " ", Point(1//2, 3//4))
for args in ((1, 2.5), ("a", "b"))
try
Point(args...)
catch err
println(first(split(sprint(showerror, err), '\n')))
end
end
# An outer constructor that promotes mixed arguments to one type first.
Point(x::Real, y::Real) = Point(promote(x, y)...)
println(Point(1, 2.5), " ", Point(1, 2//3), " ", Point(1, big(2.0)))
println("Point is a ", typeof(Point), "; Point{Float64} <: Point: ", Point{Float64} <: Point,
"; Point{Float64} <: Point{Real}: ", Point{Float64} <: Point{Real})
println("sizeof: Point{Float64} ", sizeof(Point{Float64}), " bytes, Point{Int32} ", sizeof(Point{Int32}),
" bytes; isbitstype(Point{Float64}): ", isbitstype(Point{Float64}), ", isbitstype(Point{BigFloat}): ", isbitstype(Point{BigFloat})) from session import files, run
# A parametric Point, the calls its default constructor refuses, and a promoting constructor.
files("point.jl")
run("""
julia point.jl
""") Output
$ julia point.jl
Point{Int64}(1, 2) Point{Float64}(1.5, 2.0) Point{Rational{Int64}}(1//2, 3//4)
MethodError: no method matching Point(::Int64, ::Float64)
MethodError: no method matching Point(::String, ::String)
Point{Float64}(1.0, 2.5) Point{Rational{Int64}}(1//1, 2//3) Point{BigFloat}(1.0, 2.0)
Point is a UnionAll; Point{Float64} <: Point: true; Point{Float64} <: Point{Real}: false
sizeof: Point{Float64} 16 bytes, Point{Int32} 8 bytes; isbitstype(Point{Float64}): true, isbitstype(Point{BigFloat}): false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 point_session.py
Point(1, 2) built a Point{Int64}, Point(1.5, 2.0) a Point{Float64} and Point(1//2, 3//4) a
Point{Rational{Int64}}: Julia read T from the arguments. Point("a", "b") failed because String is not a
subtype of Real, the bound the struct declares. Point(1, 2.5) failed for another reason, which the manual’s page
on constructors describes: the default constructor needs both arguments to have one type T. Step through the call:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The call. Point(1, 2.5) passes an Int64 and a Float64. The struct declares x::T and y::T, one T for both fields.
- The default constructor. Julia's default constructor Point(x::T, y::T) needs both arguments of one type T. No T is both Int64 and Float64, so it does not fit.
- Without help, an error. Before the outer constructor was defined, nothing else fitted, and the call stopped with MethodError: no method matching Point(::Int64, ::Float64).
- The outer constructor. Point(x::Real, y::Real) = Point(promote(x, y)...) accepts any two reals, so the call now reaches it.
- promote. promote(1, 2.5) converts both to their common type and returns (1.0, 2.5), two Float64 values.
- One T. Now the default constructor fits with T = Float64, and the run printed Point{Float64}(1.0, 2.5). Point(1, 2//3) gave Point{Rational{Int64}} the same way.
The outer constructor Point(x::Real, y::Real) = Point(promote(x, y)...) is the manual’s own fix. promote
converts its arguments to a common type, so Point(1, 2.5) became Point{Float64}(1.0, 2.5) and Point(1, 2//3) a
Point{Rational{Int64}}.
Point on its own is a UnionAll type: A type with a parameter left open, written with where or <:, such as Vector{T} where T<:Real, also written Vector{<:Real}. It is the set of every type it covers and is never a value's own type., the set of all its members:
Point{Float64} <: Point is true, while Point{Float64} <: Point{Real} is false, because parameters are invariant. Each member is
stored as its parameter dictates: Point{Float64} takes 16 bytes and Point{Int32} 8, both plain data, while a
Point{BigFloat} holds references to its numbers.
A parameter for a field that is always the same type
Wrong: struct Station{S<:AbstractString} name::S end when every name is a String, which only adds a parameter to
every signature. Right: name::String. A parameter is worth it when a field’s type really varies, as the
coordinates of a point or the vector inside a series do.
Checkpoint
Check: Parametric structs and promoting constructors
A where clause names a parameter and ties arguments together
A method can have type parameters too, declared after the signature with where. A name used twice must stand for
the same type in both places, which ties the arguments together; the manual’s page on methods calls such methods
parametric:
# where names a type parameter of a method, and the same name ties arguments together.
same(x::T, y::T) where {T} = "both $(T)"
same(x, y) = "different types"
for (a, b) in ((1, 2), (1, 2.0), (1.0, 2.0), ("a", "b"), (1, true))
println(rpad(repr(a) * ", " * repr(b), 12), same(a, b))
end
# T is available inside the method, here to return a zero of the element type.
lastvalue(v::AbstractVector{T}) where {T<:Real} = isempty(v) ? zero(T) : v[end]
println(lastvalue([1, 2, 3]), " ", lastvalue(Float64[]), " ", lastvalue(Int8[]), " ", typeof(lastvalue(Int8[])))
try
lastvalue(["a"])
catch err
println(first(split(sprint(showerror, err), '\n')))
end
# Two vectors with the same element type, and nothing else.
total_length(x::AbstractVector{T}, y::AbstractVector{T}) where {T} = length(x) + length(y)
println(total_length([1, 2], [3, 4]))
try
total_length([1, 2], [3.0])
catch err
println(first(split(sprint(showerror, err), '\n')))
end from session import files, run
# Three methods with where clauses, and the calls each one accepts or refuses.
files("where_clauses.jl")
run("""
julia where_clauses.jl
""") Output
$ julia where_clauses.jl
1, 2 both Int64
1, 2.0 different types
1.0, 2.0 both Float64
"a", "b" both String
1, true different types
3 0.0 0 Int8
MethodError: no method matching lastvalue(::Vector{String})
4
MethodError: no method matching total_length(::Vector{Int64}, ::Vector{Float64})
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 where_clauses_session.py
same(x::T, y::T) where {T} matched (1, 2) and (1.0, 2.0), but not (1, 2.0) or (1, true): T must be one
concrete type, and Int64, Float64 and Bool are three. The catch-all same(x, y) took the rest.
T is also a name the body can use. lastvalue(v::AbstractVector{T}) where {T<:Real} returned zero(T) for an
empty vector, so an empty Int8[] gave an Int8 zero, and <:Real refused a vector of strings. total_length
demanded the same element type for both vectors and refused a Vector{Int64} with a Vector{Float64}. The three
forms side by side:
| Signature | Accepts | T inside the body |
|---|---|---|
f(v::AbstractVector{<:Real}) |
any vector of reals | no name for it; use eltype(v) |
f(v::AbstractVector{T}) where {T<:Real} |
the same vectors | T, the element type |
f(x::AbstractVector{T}, y::AbstractVector{T}) where {T} |
two vectors of one element type | T |
Tie types only when the method needs it
A where clause that forces two arguments to share a type refuses Int64 with Float64, which is usually a
normal call. Tie the types when the code really needs one type, for example to store both in one vector without
conversion, and otherwise accept each argument on its own and promote inside.
Checkpoint
Check: Where clauses that tie argument types
Generic numeric code takes its types from the data
Two means of the same five inputs differ in one line. mean_fixed starts its sum at the literal 0.0, and
mean_generic at zero(eltype(v)), a zero of the data’s own type:
# Two means: one starts from the literal 0.0, the other from a zero of the data's own type.
function mean_fixed(v)
s = 0.0
for x in v
s += x
end
return s / length(v)
end
function mean_generic(v)
s = zero(eltype(v))
for x in v
s += x
end
return s / length(v)
end
println(rpad("input", 16), rpad("mean_fixed", 11), rpad("mean_generic", 17), "mean_generic's value")
for v in (Float32[1.5, 2.5, 4.0], [1.5, 2.5, 4.0], big.([1.5, 2.5, 4.0]), [1//3, 1//6, 1//2], [1, 2, 4])
println(rpad(string(eltype(v)), 16), rpad(string(typeof(mean_fixed(v))), 11),
rpad(string(typeof(mean_generic(v))), 17), mean_generic(v))
end
# The helpers that keep a computation in its type.
println("zero(Float32) = ", zero(Float32), ", one(BigFloat) = ", one(BigFloat), ", oftype(1f0, 2) = ", oftype(1f0, 2),
", float(Int) = ", float(Int), ", eps(Float32) = ", eps(Float32)) from session import files, run
# The type each mean returns for five kinds of input.
files("generic_mean.jl")
run("""
julia generic_mean.jl
""") Output
$ julia generic_mean.jl
input mean_fixed mean_generic mean_generic's value
Float32 Float64 Float32 2.6666667
Float64 Float64 Float64 2.6666666666666665
BigFloat BigFloat BigFloat 2.666666666666666666666666666666666666666666666666666666666666666666666666666678
Rational{Int64} Float64 Rational{Int64} 1//3
Int64 Float64 Float64 2.3333333333333335
zero(Float32) = 0.0, one(BigFloat) = 1.0, oftype(1f0, 2) = 2.0, float(Int) = Float64, eps(Float32) = 1.1920929e-7
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 generic_mean_session.py
The literal 0.0 is a Float64, and every addition promoted the sum to it: Float32 data came back as Float64, and
the exact fractions 1//3, 1//6 and 1//2 as a rounded Float64 instead of 1//3. Starting from
zero(eltype(v)), each mean stayed in its input’s type. Integer data still gave a Float64, because / between two
integers returns a float.
A tolerance written for Float64
Wrong: while abs(dx) > 1e-12. A Float32 step meets it only by becoming exactly zero, since the spacing of
Float32 numbers near 1 is 1.1920929e-7, and an iteration that alternates between two neighbouring values never
stops. Right: while abs(dx) > 100 * eps(typeof(x)), or a tolerance argument with a default taken from eps(T).
The same functions keep any computation in its type: zero(T) and one(T) for starting values, oftype(x, 2) to
write a constant in x’s type, float(T) for the floating-point type that matches T, and eps(T) for a tolerance
that fits its precision, 1.1920929e-7 for Float32. Code written this way serves Float32 on a GPU, Float64 for
everyday work and BigFloat to check a result to more digits.
Key idea
Write constants through the data’s type, zero(T), one(T), oftype(x, c) and eps(T), and the same code keeps
Float32, BigFloat and Rational inputs in their own types.
Checkpoint
Check: Numeric code in the data's own type
Abstract field types cost speed, and type parameters remove the cost
The performance tips name abstract field types as a reason the compiler cannot generate fast code. Two structs hold the same million values and the same scale, one with abstract field types and one with type parameters:
# The same series stored in a struct with abstract field types and in one with type parameters.
struct LooseSeries
t::AbstractVector
scale::Real
end
struct TightSeries{V<:AbstractVector{<:Real}, S<:Real}
t::V
scale::S
end
function total(s)
acc = zero(s.scale)
for x in s.t
acc += s.scale * x
end
return acc
end
data = collect(range(0.0, 1.0; length = 1_000_000))
loose = LooseSeries(data, 2.0)
tight = TightSeries(data, 2.0)
total(loose); total(tight) # the first calls compile
println("tight is a ", typeof(tight))
println("same total: ", total(loose) == total(tight))
println("allocated by one call: loose ", @allocated(total(loose)), " bytes, tight ", @allocated(total(tight)), " bytes")
println("inferred result: loose ", only(Base.return_types(total, (LooseSeries,))), ", tight ", only(Base.return_types(total, (typeof(tight),))))
r = TightSeries(1:1_000_000, 2.0) # a range works too, with its own concrete type
total(r)
println(typeof(r), ": allocated ", @allocated(total(r)), " bytes")
a = TightSeries{AbstractVector{Float64}, Real}(data, 2.0) # abstract parameters written out by hand
total(a)
println(typeof(a), ": allocated ", @allocated(total(a)), " bytes") from session import files, run
# A loop over a struct's fields, with abstract field types and with type parameters.
files("abstract_fields.jl")
run("""
julia abstract_fields.jl
""") Output
$ julia abstract_fields.jl
tight is a TightSeries{Vector{Float64}, Float64}
same total: true
allocated by one call: loose 95991840 bytes, tight 0 bytes
inferred result: loose Any, tight Float64
TightSeries{UnitRange{Int64}, Float64}: allocated 0 bytes
TightSeries{AbstractVector{Float64}, Real}: allocated 95991840 bytes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 abstract_fields_session.py
Step through what the compiler knows about each struct when it compiles total:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Two structs. Both hold the same million Float64 values and the scale 2.0. LooseSeries declares its fields with abstract types; TightSeries takes them from type parameters.
- What the struct says. Every LooseSeries has the same type, so its type says only that t is some AbstractVector and scale some Real.
- What the parameters say. tight has the type TightSeries{Vector{Float64}, Float64}: the parameters name the exact field types, and each pair of types is its own concrete type.
- total on loose. Compiled for LooseSeries, total cannot know the element type or the scale's type, so each step is chosen while the loop runs: 95,991,840 bytes allocated, the result inferred as Any.
- total on tight. Compiled for TightSeries{Vector{Float64}, Float64}, the loop works on known Float64 values: 0 bytes allocated, the result inferred as Float64.
The totals were equal, but one call on loose allocated 95,991,840 bytes, about 96 for each value, and its result
was inferred only as Any. Every LooseSeries has the same type whatever it holds, so the type tells the compiler
nothing about the fields, and each step of the loop was chosen while it ran. tight has the type
TightSeries{Vector{Float64}, Float64}, which names the field types, so the loop compiled to arithmetic on known
Float64 values and allocated nothing. A range in the same struct made another concrete type,
TightSeries{UnitRange{Int64}, Float64}, and allocated nothing either.
The performance tips add a caveat: a parametric struct built with abstract parameters behaves like the loose one. The
last line of the run wrote them out by hand, TightSeries{AbstractVector{Float64}, Real}(data, 2.0), and one call
allocated 95,991,840 bytes again. Let the constructor read the parameters from the arguments, as
TightSeries(data, 2.0) did.
Checkpoint
Check: Abstract field types against type parameters
NTuple puts a length into a type
A tuple’s type lists the type of every position, so its length is part of its type. NTuple{N, T} is short for a
tuple of N values of type T, and a method can take N as a parameter:
# A size in the type: NTuple{N, T} is a tuple of N values of type T.
dot3 = (1.0, 2.0, 3.0)
println(typeof(dot3), " ", typeof(dot3) == NTuple{3, Float64}, " ", sizeof(dot3), " bytes, isbits: ", isbits(dot3))
println(ntuple(i -> i^2, 4))
# N is known when the method is compiled, so mismatched lengths are refused before the code runs.
dot(a::NTuple{N, T}, b::NTuple{N, T}) where {N, T<:Real} = sum(a[i] * b[i] for i in 1:N)
println(dot((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)))
try
dot((1.0, 2.0, 3.0), (4.0, 5.0))
catch err
println(first(split(sprint(showerror, err), '\n')))
end
println("allocated by a second call: ", @allocated(dot((1.0, 2.0, 3.0), (4.0, 5.0, 6.0))), " bytes") from session import files, run
# Tuples whose length is part of their type.
files("ntuple.jl")
run("""
julia ntuple.jl
""") Output
$ julia ntuple.jl
Tuple{Float64, Float64, Float64} true 24 bytes, isbits: true
(1, 4, 9, 16)
32.0
MethodError: no method matching dot(::Tuple{Float64, Float64, Float64}, ::Tuple{Float64, Float64})
allocated by a second call: 0 bytes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ntuple_session.py
(1.0, 2.0, 3.0) is an NTuple{3, Float64}: 24 bytes of plain data with no pointers.
dot(a::NTuple{N, T}, b::NTuple{N, T}) demands the same N for both arguments, so tuples of 3 and 2 values were refused at dispatch, before any code
ran, with a MethodError. N is a constant of the compiled method, so the compiler knows how many times the loop
over 1:N runs, and a second call allocated nothing.
Packages such as StaticArrays also put the size into the type, for fixed-size vectors and matrices, a subject of the
module on fast code.
Not for sizes that change
Encode a length in a type only when it is small and fixed, such as the 3 coordinates of a point in space. Julia
compiles a new version of a method for each distinct N, so lengths that vary with the data would compile one
version per length.
Checkpoint
Check: A length in the type with NTuple
Practise: a polynomial type
Polynomial{T}, evaluated by Horner's rule
Write struct Polynomial{T<:Number} holding a coefficient vector, lowest power first, and an outer constructor that
promotes mixed coefficients, so Polynomial([1, 2.5]) is a Polynomial{Float64}. Then write (p::Polynomial)(x),
which evaluates p at x by Horner’s rule starting from zero, never 0.0. Check on your machine that
Polynomial([1//2, 1//3])(3//1) returns the exact 3//2, that a Float32 polynomial at a Float32 point returns a
Float32, and that @allocated reports 0 bytes for an evaluation after the first.
Then test yourself on parametric structs, where clauses, generic code, abstract fields and NTuple. The session
picks your least secure idea first, and every choice comes with its reason:
Practice that adapts to you
Practise this lesson
11 questions about 5 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): what does the T in struct Point{T} do?
T is a type parameter: it makes Point a family of types, one for each T, such as Point{Float64} and
Point{Rational{Int64}}. Each member is a concrete type whose fields have the type T, so the compiler knows exactly
how a Point{Float64} is stored, 16 bytes of plain data, and compiles fast code for it. Julia reads T from the
arguments, Point(1.5, 2.0) is a Point{Float64}, and a bound such as T<:Real refuses other types. Point alone is
the set of all its members, so a method written for Point accepts every one, while Point{Real} is a different,
slower type that Point{Float64} is not a subtype of. Parameters replace abstract field types without losing
generality.
Two design questions grow out of this lesson: a simulation struct that allocates in its inner loop, and a numerical routine that changes the type of its results. For each, say which field or constant you would change, then read the first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
A simulation keeps its state in struct Sim with the fields state::AbstractVector, dt::Real and params::Dict{Symbol, Any}. Profiling shows the time-step loop allocating on every iteration. Explain why, and redesign the struct without losing the ability to run on Float32 data or on GPU arrays.
-
Fresher to senior
A numerical routine written with s = 0.0, 1/n and a tolerance of 1e-12 returns Float64 for Float32 inputs, loses exactness for Rational inputs, and never stops for one user's Float32 data. How do you make it generic, and how do you test that it stays generic?
Key takeaways
- A type parameter makes a family of concrete types:
Point(1, 2)is aPoint{Int64}andPoint(1//2, 3//4)aPoint{Rational{Int64}}, andPointalone is aUnionAllof them all. - The default constructor of
Point{T}needs oneTfor both fields, so mixed inputs need an outer constructor that callspromote. - A
whereclause names a method’s type parameters; a name used twice ties the arguments to one concrete type, and the body can use the name. - Generic numeric code writes constants through the data’s type, so
Float32,BigFloatandRationalinputs keep their types instead of falling back toFloat64. - Abstract field types made one call allocate 95,991,840 bytes and infer
Any; type parameters gave the same results with 0 bytes. NTuple{N, T}puts a small, fixed length into a type, so mismatched lengths fail at dispatch, and the run’sdotover1:Nallocated nothing.
Terms in this lesson
- parametric type
- A type defined with type parameters, such as struct Point{T<:Real}. It stands for a whole family of types, one concrete member for each parameter, and alone it is a UnionAll type.
- type parameter
- A type written in braces after a type's name, such as T in Point{T}, that the type's fields or a method's arguments refer to. Each value of T gives another type of the family, such as Point{Float64}.
- UnionAll type
- A type with a parameter left open, written with where or <:, such as Vector{T} where T<:Real, also written Vector{<:Real}. It is the set of every type it covers and is never a value's own type.
References
- Types: parametric composite types, UnionAll types and tuple types (The Julia Project)
- Methods: parametric methods (The Julia Project)
- Constructors: parametric constructors (The Julia Project)
- Performance tips: avoid fields with abstract type (The Julia Project)
- Numbers: zero, one, oftype, float and eps (The Julia Project)
- StaticArrays.jl: statically sized arrays (JuliaArrays)
Report a problem with this lesson
Kept only in this browser. Your Learn progress