Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Julia  Module 6 – The type system and multiple dispatch

The type system: abstract and concrete types

Walk Julia’s type tree with supertype, subtypes and <:, tell abstract from concrete types, and see why Vector{Int} is no Vector{Real} but fits Vector{<:Real}.

  • Intermediate
  • 15 minutes
  • Examples run with julia 1.13.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Navigate the Number hierarchy with supertype, subtypes and the <: operator
  • Distinguish abstract from concrete types, and explain why a Vector{Real} is concrete but slow
  • Write signatures with AbstractVector{<:Real}, and explain why type parameters are invariant
  • Use a Union for a result that may be nothing, and handle both cases where it is called
  • Predict what a :: annotation does in an expression, a signature and a local variable

Before you start

What you’ll learn · Before you start

What you will learn

  • Navigate the Number hierarchy with supertype, subtypes and the <: operator
  • Distinguish abstract from concrete types, and explain why a Vector{Real} is concrete but slow
  • Write signatures with AbstractVector{<:Real}, and explain why type parameters are invariant
  • Use a Union for a result that may be nothing, and handle both cases where it is called
  • Predict what a :: annotation does in an expression, a signature and a local variable

Before you start

On this page

Every integer is a real number, yet a function declared for a Vector{Real} refused [1, 2, 3] with a MethodError. And a Vector{Real} of a million numbers took 16,000,040 bytes where a Vector{Float64} of the same numbers took 8,000,040. Both facts come from one design: Julia’s types form a tree, and the types inside it, the abstract ones, say what a value is but never how it is stored.

This lesson walks the tree of number types, separates abstract from concrete types, explains why a vector’s type parameter does not follow the tree and what to write instead, uses a Union for a result that may be missing, and sorts out the three jobs of ::. The runs below use Julia 1.13.1.

Every value has one concrete type, and abstract types group them

typeof(42) is Int64 and typeof(2.5) is Float64: each value has exactly one type, and it is always a concrete type: A type that values can have, such as Int64, Float64 or Vector{Real}. typeof always returns one. Concrete types are final: they cannot have subtypes, so values sit at the leaves of the type tree.. Above them sit the abstract type: A type that no value has directly, such as Integer, Real or Number. It groups other types in the type tree, so that isa tests and method signatures can treat them alike; only abstract types may have subtypes., which no value has directly. supertype(Int64) is Signed, supertype(Signed) is Integer, and so on up to Any, the type at the top of every chain, as the lesson on variables showed with isa. This lesson walks the other way, down from Number with subtypes:

number_tree.jl: the types below Number, walked with subtypes Julia · number_tree.jl
# The tree of number types below Number, each marked abstract, concrete or parametric.
using InteractiveUtils   # subtypes is a REPL helper that a script loads

exported(T) = Base.isexported(Base, nameof(T))   # leave out Base's internal helper types

function kind(T)
    isabstracttype(T) && return "abstract"
    isconcretetype(T) && return "concrete"
    return "parametric"                          # Complex, Rational: a family of concrete types
end

const hidden = Any[]                           # the internal types the walk leaves out
function tree(T, depth = 0)
    println("  "^depth, T, "  (", kind(T), ")")
    for S in subtypes(T)
        exported(S) ? tree(S, depth + 1) : push!(hidden, S)
    end
end

tree(Number)
println("left out, not exported by Base: ", join(string.(hidden), ", "))
println("Int64 <: Signed <: Integer <: Real <: Number: ", Int64 <: Signed <: Integer <: Real <: Number)
println("supertype(Float64) = ", supertype(Float64), ", subtypes(Integer) = ", subtypes(Integer))
println("true + true = ", true + true, ", because Bool <: Integer is ", Bool <: Integer)
number_tree.jl, run with Julia 1.13.1 Python · number_tree_session.py
from session import files, run

# The number types below Number, walked with subtypes.
files("number_tree.jl")
run("""
julia number_tree.jl
""")

Output

$ julia number_tree.jl
Number  (abstract)
  Complex  (parametric)
  Real  (abstract)
    AbstractFloat  (abstract)
      BigFloat  (concrete)
      Float16  (concrete)
      Float32  (concrete)
      Float64  (concrete)
    AbstractIrrational  (abstract)
      Irrational  (parametric)
    Integer  (abstract)
      Bool  (concrete)
      Signed  (abstract)
        BigInt  (concrete)
        Int128  (concrete)
        Int16  (concrete)
        Int32  (concrete)
        Int64  (concrete)
        Int8  (concrete)
      Unsigned  (abstract)
        UInt128  (concrete)
        UInt16  (concrete)
        UInt32  (concrete)
        UInt64  (concrete)
        UInt8  (concrete)
    Rational  (parametric)
left out, not exported by Base: Base.MultiplicativeInverses.MultiplicativeInverse, Core.BFloat16
Int64 <: Signed <: Integer <: Real <: Number: true
supertype(Float64) = AbstractFloat, subtypes(Integer) = Any[Bool, Signed, Unsigned]
true + true = 2, because Bool <: Integer is true

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 number_tree_session.py

The printed tree holds every number type that Base exports, and the line after it names the two internal types the walk left out. AbstractFloat gathers the four floating-point types, Signed the five fixed-size signed integers and BigInt, and Integer gathers Signed, Unsigned and Bool: a Bool is an integer, which is why true + true is 2. The subtype: A type that sits below another in the type tree. T <: S asks whether T is a subtype of S: Int64 <: Real is true, and every type is a subtype of itself and of Any. operator <: asks whether one type sits below another, and it chains: Int64 <: Signed <: Integer <: Real <: Number printed true. subtypes returns a vector of the types one level down, and in a script it comes from InteractiveUtils, which the REPL loads by itself. The same tree, drawn with each abstract type around its subtypes:

Number contains Complex and Real; Real contains AbstractFloat, Integer, Rational and AbstractIrrational, each holding its concrete types.Number (abstract)Complex{T}, parametric:ComplexF64, ComplexF32 …Real (abstract)AbstractFloat (abstract):Float16, Float32,Float64, BigFloatInteger (abstract)Rational{T}, parametricAbstractIrrational:Irrational, as π and ℯBoolSigned (abstract):Int8, Int16, Int32,Int64, Int128, BigIntUnsigned (abstract):UInt8, UInt16, UInt32,UInt64, UInt128

The number types of Base, each abstract type drawn around its subtypes

Text description of the diagram

Number, Real and Integer are drawn as boxes around their subtypes. Each smaller box names a type, and for an abstract type it lists the concrete types below it, the types values have.

  1. Number, abstract, holds Complex and Real.
  2. Complex{T} is parametric: a family of concrete types such as ComplexF64 and ComplexF32.
  3. Real, abstract, holds AbstractFloat, Integer, Rational and AbstractIrrational.
  4. AbstractFloat, abstract, has the concrete types Float16, Float32, Float64 and BigFloat below it.
  5. Integer, abstract, holds the concrete type Bool and the abstract types Signed and Unsigned.
  6. Signed has Int8, Int16, Int32, Int64, Int128 and BigInt below it; Unsigned has UInt8, UInt16, UInt32, UInt64 and UInt128.
  7. Rational{T} is parametric, and AbstractIrrational has Irrational below it, the type of constants such as π and ℯ.

Three kinds of type appear in it, and this program tests each one:

kinds.jl: abstract, concrete, and the family in between Julia · kinds.jl
# Abstract types have no values of their own, and concrete types have no subtypes.
for T in (Real, Float64, Complex, Complex{Float64}, Vector{Real})
    println(rpad(string(T), 18), "isabstracttype ", rpad(isabstracttype(T), 7), "isconcretetype ", isconcretetype(T))
end
x = AbstractFloat(1)                  # calling an abstract type converts to a concrete one
println("AbstractFloat(1) = ", x, ", a ", typeof(x))
try
    eval(:(struct Precise <: Float64 end))
catch err
    println(sprint(showerror, err))
end
kinds.jl, run with Julia 1.13.1 Python · kinds_session.py
from session import files, run

# Which types are abstract and which concrete, and the one rule concrete types follow.
files("kinds.jl")
run("""
julia kinds.jl
""")

Output

$ julia kinds.jl
Real              isabstracttype true   isconcretetype false
Float64           isabstracttype false  isconcretetype true
Complex           isabstracttype false  isconcretetype false
ComplexF64        isabstracttype false  isconcretetype true
Vector{Real}      isabstracttype false  isconcretetype true
AbstractFloat(1) = 1.0, a Float64
invalid subtyping in definition of Precise: can only subtype abstract types.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 kinds_session.py

Real is abstract and Float64 concrete. Complex is neither: it is a family of concrete types, one for each type parameter, and Complex{Float64}, printed ComplexF64, is one member. The manual states two rules that the run shows. Abstract types cannot be instantiated: AbstractFloat(1) made no value of type AbstractFloat but returned 1.0, a Float64, because Base defines that call as a conversion to a concrete floating-point type. And concrete types are final, they may only have abstract supertypes, so struct Precise <: Float64 end stopped with invalid subtyping. A tree built this way has values only at its leaves.

Checkpoint

Check: The type tree: supertype, subtypes and <:

  1. Question 1 of 2 Which of these is false?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Float64 <: Integer

    <: asks whether a type sits below another in the tree. Float64 and Integer are on different branches below Real, so Float64 <: Integer is false.

  2. Question 2 of 2 What is true + true + true?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 3

    Bool is a subtype of Integer, and true behaves as the integer 1 in arithmetic, so the sum is 3, as true + true was 2 in the run.

A vector of an abstract type is concrete, and stores every value elsewhere

isconcretetype(Vector{Real}) is true, which surprises most people the first time. The vector type is concrete, because values of it exist; its element type, Real, is abstract. What that costs shows up when the same million numbers go into a Vector{Float64} and a Vector{Real}:

storage.jl: one million numbers stored two ways Julia · storage.jl
# The same million numbers in a Vector{Float64} and in a Vector{Real}.
function total(v)
    s = 0.0
    for x in v
        s += x
    end
    return s
end
xf = [Float64(k) for k in 1:1_000_000]
xr = Real[Float64(k) for k in 1:1_000_000]
total(xf); total(xr)                         # the first calls compile
println("isconcretetype(Vector{Real}): ", isconcretetype(Vector{Real}), ", eltype: ", eltype(xr), ", typeof(xr[1]): ", typeof(xr[1]))
println("isbitstype: Float64 ", isbitstype(Float64), ", Real ", isbitstype(Real))
println("sizeof, the slots only:    Float64 ", sizeof(xf), " bytes, Real ", sizeof(xr), " bytes")
println("summarysize, with values:  Float64 ", Base.summarysize(xf), " bytes, Real ", Base.summarysize(xr), " bytes")
println("one pass of total:         Float64 ", @allocated(total(xf)), " bytes allocated, Real ", @allocated(total(xr)), " bytes")
println("type the compiler infers:  Float64 ", only(Base.return_types(total, (Vector{Float64},))), ", Real ", only(Base.return_types(total, (Vector{Real},))))
storage.jl, run with Julia 1.13.1 Python · storage_session.py
from session import files, run

# What a Vector{Real} stores, compared with a Vector{Float64} of the same values.
files("storage.jl")
run("""
julia storage.jl
""")

Output

$ julia storage.jl
isconcretetype(Vector{Real}): true, eltype: Real, typeof(xr[1]): Float64
isbitstype: Float64 true, Real false
sizeof, the slots only:    Float64 8000000 bytes, Real 8000000 bytes
summarysize, with values:  Float64 8000040 bytes, Real 16000040 bytes
one pass of total:         Float64 0 bytes allocated, Real 16000000 bytes
type the compiler infers:  Float64 Float64, Real Any

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 storage_session.py

Step through what each vector keeps in its slots:

Interactive · step by step The same million numbers in a Vector{Float64} and a Vector{Real} Step through what each vector keeps in its slots, then what one pass of total does with each.
The same million numbers in a Vector{Float64} and a Vector{Real}, step 6 of 6. Showing Vector{Float64}, the numbers themselves, total(xf), Vector{Real}, an address in each slot, a million objects. In focus: an address in each slot, a million objects.holdsread in orderholdspoints toVector{Float64}a million 8-byte slotsthe numbers themselves1.0, 2.0, 3.0, …total(xf)+ chosen when compiledVector{Real}a million 8-byte slotsan address in each slotpoints to the valuea million objectseach: type tag + value16,000,000 bytes per pass

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Two vectors. xf and xr hold the same numbers, 1.0 to 1,000,000.0. Both have 1,000,000 slots of 8 bytes, so sizeof printed 8,000,000 bytes for each.
  2. Float64 in place. Every Float64 is 8 bytes, so a slot of xf holds the number itself. summarysize, which also counts what the slots point to, printed 8,000,040 bytes.
  3. Real cannot fit. A slot of xr must take any Real: a Float64, a 16-byte Int128, a BigFloat of any size. No one size fits them all, so each slot holds the address of the value instead.
  4. One object per value. Each value sits in an object of its own, with a tag that names its type. summarysize printed 16,000,040 bytes for xr, almost twice as much as for xf.
  5. The loop over xf. The compiler knows every element of xf is a Float64, so it picks the + method once and the loop runs on the values in order: 0 bytes allocated, and the result inferred as Float64.
  6. The loop over xr. For xr each x is read through its address and its tag decides the + method while the loop runs. One pass allocated 16,000,000 bytes, and the result was inferred only as Any.

Both vectors have a million slots of 8 bytes, so sizeof gave 8,000,000 bytes for each. A Float64 fits a slot, and isbitstype(Float64) is true: it is plain data with no references. A Real could be any real number, a 16-byte Int128 or a BigFloat of any size, so the manual explains that an array of an abstract element type must hold pointers to individually allocated objects. Base.summarysize, which follows the pointers, counted 16,000,040 bytes for the Vector{Real}.

An abstract element type to make code general

Wrong: readings = Real[] so that a function can take integers and floats. Every reading is then a separate object. Right: give the container a concrete element type, Float64[], and make the function general instead, with no annotation or an abstract one on its argument: Julia compiles a separate version for each concrete type it is called with.

The loop pays again. Over xf, the compiler knew every element was a Float64, chose + once and allocated nothing. Over xr, the type of each element was found while the loop ran, every s += x made a new object, and one pass allocated 16,000,000 bytes. Base.return_types shows the cause: the result was inferred as Float64 for the first vector and only as Any for the second.

Key idea

An abstract type in a container’s braces makes every element a separate object reached through a pointer; keep abstract types in function signatures and concrete types in the braces of your data.

Checkpoint

Check: Abstract element types and storage

  1. Question 1 of 2 Which of these are concrete types? Choose every one that is.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Float64
    • Vector{Real}

    A concrete type is one that values have. A vector type is concrete even with an abstract element type; the element type then decides how each slot is stored.

  2. Question 2 of 2 summarysize counted 8,000,040 bytes for a million numbers in a Vector{Float64} and 16,000,040 in a Vector{Real}. How many bytes more did it count for each element of the Vector{Real}?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 8 bytes

    The difference is 8,000,000 bytes for 1,000,000 elements, 8 bytes each: the slot holds an address, and summarysize also counted the 8 bytes of the separate object that holds the value.

A vector of integers is not a vector of reals: type parameters are invariant

Two functions compute the same mean. The first is declared for Vector{Real}, the second for AbstractVector{<:Real}, and each is called with six vectors of real numbers:

invariance.jl: two signatures, six vectors of reals Julia · invariance.jl
# A Vector{Real} parameter accepts only a Vector{Real}; AbstractVector{<:Real} accepts every vector of reals.
mean_strict(v::Vector{Real}) = sum(v) / length(v)
mean_wide(v::AbstractVector{<:Real}) = sum(v) / length(v)

inputs = (("[1, 2, 3]", [1, 2, 3]), ("[1.5, 2.5]", [1.5, 2.5]), ("Real[1, 2.5]", Real[1, 2.5]),
          ("Float32[1, 2]", Float32[1, 2]), ("1:4", 1:4), ("view(w, 2:3)", view([1.0, 2.0, 3.0], 2:3)))
for (code, v) in inputs
    strict = try
        mean_strict(v)
    catch err
        err isa MethodError ? "MethodError" : rethrow()
    end
    println(rpad(code, 15), rpad(string(strict), 13), mean_wide(v))
end
println("Vector{Int} <: Vector{Real}:   ", Vector{Int} <: Vector{Real})
println("Vector{Int} <: Vector{<:Real}: ", Vector{Int} <: Vector{<:Real})
println("UnitRange{Int} <: AbstractVector{<:Real}: ", UnitRange{Int} <: AbstractVector{<:Real})
println("Tuple{Int} <: Tuple{Real}:     ", Tuple{Int} <: Tuple{Real})

# Why: a Vector{Real} must take any real number, and a Vector{Int} cannot.
r = Real[1, 2]
push!(r, 2.5)
println(r)
i = [1, 2]
try
    push!(i, 2.5)
catch err
    println(sprint(showerror, err))
end
invariance.jl, run with Julia 1.13.1 Python · invariance_session.py
from session import files, run

# Two signatures, six vectors of real numbers.
files("invariance.jl")
run("""
julia invariance.jl
""")

Output

$ julia invariance.jl
[1, 2, 3]      MethodError  2.0
[1.5, 2.5]     MethodError  2.0
Real[1, 2.5]   1.75         1.75
Float32[1, 2]  MethodError  1.5
1:4            MethodError  2.5
view(w, 2:3)   MethodError  2.5
Vector{Int} <: Vector{Real}:   false
Vector{Int} <: Vector{<:Real}: true
UnitRange{Int} <: AbstractVector{<:Real}: true
Tuple{Int} <: Tuple{Real}:     true
Real[1, 2, 2.5]
InexactError: Int64(2.5)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 invariance_session.py

mean_strict accepted one input of the six, Real[1, 2.5]. The manual calls Julia’s type parameters invariant: Said of a parametric type whose parameters do not pass their subtype order on to it, as Julia's are: Int64 <: Real is true, but Vector{Int64} <: Vector{Real} is false. Tuple types are the exception.: although Int64 <: Real, Vector{Int64} <: Vector{Real} is false, because the two types have different representations in memory. The last lines show the promise a Vector{Real} makes and a Vector{Int64} cannot keep: any real number may be stored in it. push!(r, 2.5) worked on the Vector{Real}, while push!(i, 2.5) on [1, 2] stopped with InexactError: Int64(2.5).

Vector{<:Real} is the set of every Vector whose element type is a subtype of Real, 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. More in “Parametric types and generic numeric code” that is short for Vector{T} where T<:Real, so Vector{Int} <: Vector{<:Real} printed true. AbstractVector{<:Real} is wider still: it also holds ranges such as 1:4 and views, which are vectors of reals but not Vectors. The sets nest like this:

AbstractVector{<:Real} holds Vector{<:Real}, the Vector types such as Vector{Int64} and Vector{Real}, and also ranges and views.AbstractVector{<:Real}Vector{<:Real}not a VectorVector{Int64}Vector{Float64}Vector{Float32}Vector{Real}UnitRange{Int64}: 1:4SubArray: a view

The vector types each signature accepts

Text description of the diagram

Each box is the set of types a signature with that type accepts.

  1. The outer box, AbstractVector{<:Real}, holds every vector whose element type is a subtype of Real.
  2. Inside it, Vector{<:Real} holds the Vector types: Vector{Int64}, Vector{Float64}, Vector{Float32} and Vector{Real}.
  3. Vector{Real} is one of them, a single concrete type, so a parameter declared Vector{Real} accepts only that one box.
  4. AbstractVector{<:Real} also holds vectors of reals that are not a Vector at all: ranges such as 1:4, a UnitRange{Int64}, and views such as view(w, 2:3), a SubArray.

Tuples are the exception the run also printed: they are covariant: Said of a type whose parameters pass their subtype order on to it, as Julia's tuple types do: Tuple{Int64} <: Tuple{Real} is true. Other parametric types are invariant, so Vector{Int64} <: Vector{Real} is false. More in “Tuples, named tuples and pairs”, so Tuple{Int} <: Tuple{Real} is true. A tuple can never be written to, so it can break no promise about what may be stored in it.

The usual signature for numeric vectors

Write v::AbstractVector{<:Real}, or leave the argument untyped. It costs no speed: the call mean_wide([1, 2, 3]) runs code compiled for Vector{Int64}, and mean_wide(1:4) code compiled for UnitRange{Int64}.

Checkpoint

Check: Invariant type parameters and <:Real

  1. Question 1 of 2 Which signature accepts 1:4, [1, 2] and Float32[1.5] alike?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: f(v::AbstractVector{<:Real})

    AbstractVector{<:Real} is the set of every vector type whose element type is below Real, ranges and views included.

  2. Question 2 of 2 What does Vector{Int} <: Vector{Real} return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: false

    A Vector{Real} may hold any real number in any slot, which a Vector{Int} cannot, and the two are stored differently, so neither is a subtype of the other.

A Union holds one of a few types, such as a result or nothing

A lookup sometimes has no answer. rainfall(city) below reads a table of invented yearly totals and returns nothing for a city it does not have, so its result is one of two types:

unions.jl: a lookup that may return nothing Julia · unions.jl
# A lookup that returns a number, or nothing when it has no data.
const RAINFALL_MM = Dict("Pune" => 722.0, "Chennai" => 1400.0)   # invented yearly totals
rainfall(city) = get(RAINFALL_MM, city, nothing)

println(typeof(rainfall("Pune")), " ", typeof(rainfall("Delhi")))
println("inferred: ", only(Base.return_types(rainfall, (String,))))
println(Union{Int, Nothing}, " ", 1 isa Union{Int, Nothing}, " ", nothing isa Union{Int, Nothing})

for city in ("Pune", "Delhi")                    # the caller handles both cases
    mm = rainfall(city)
    if mm === nothing
        println(city, ": no data")
    else
        println(city, ": ", mm / 10, " cm")
    end
end
println("something(rainfall(\"Delhi\"), 0.0) = ", something(rainfall("Delhi"), 0.0))

try                                              # a caller that forgot the second case
    rainfall("Delhi") + 1
catch err
    println(first(split(sprint(showerror, err), '\n')))
end

# A small Union is stored compactly in an array: the values in one block, plus one type byte each.
gaps = Union{Missing, Float64}[isodd(k) ? missing : Float64(k) for k in 1:1000]
boxed = Any[isodd(k) ? missing : Float64(k) for k in 1:1000]
println("1,000 values: Union{Missing, Float64} ", Base.summarysize(gaps), " bytes, Any ", Base.summarysize(boxed), " bytes")
unions.jl, run with Julia 1.13.1 Python · unions_session.py
from session import files, run

# A result or nothing, and the cost of a small Union in an array.
files("unions.jl")
run("""
julia unions.jl
""")

Output

$ julia unions.jl
Float64 Nothing
inferred: Union{Nothing, Float64}
Union{Nothing, Int64} true true
Pune: 72.2 cm
Delhi: no data
something(rainfall("Delhi"), 0.0) = 0.0
MethodError: no method matching +(::Nothing, ::Int64)
1,000 values: Union{Missing, Float64} 9040 bytes, Any 12040 bytes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unions_session.py

The compiler inferred Union{Nothing, Float64} for the result. A Union: A type whose values have one of the listed member types: Union{Nothing, Float64} holds nothing or a Float64. The compiler handles unions of a few types efficiently, in code and in arrays. is the type of a value that has one of the listed types: 1 isa Union{Int, Nothing} and nothing isa Union{Int, Nothing} are both true, and Julia prints the members in its own order, Union{Nothing, Int64}. Follow both calls through the caller:

Interactive · step by step A result or nothing, and the caller that checks Step through two calls of rainfall, one for a city in the table and one for a city that is not, then the caller that forgot to check.
A result or nothing, and the caller that checks, step 5 of 5. Showing rainfall(city), mm (mm = nothing), mm === nothing ?, mm / 10, report no data, rainfall("Delhi") + 1. In focus: rainfall("Delhi") + 1.returnsno: a Float64yes: nothingrainfall(city)get(table, city, nothing)mmmm = nothingmm === nothing ?the caller checksmm / 10Pune: 72.2 cmreport no dataDelhi: no datarainfall("Delhi") + 1no checkMethodError: +(::Nothing, ::Int64)

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The return type. rainfall returns a Float64 for a city in the table and nothing for any other, so the compiler inferred Union{Nothing, Float64} for its result.
  2. Pune. For Pune the table has 722.0, so mm is a Float64. The caller tests mm === nothing, which is false.
  3. The value. Only now does the caller divide: 722.0 / 10 printed as 72.2 cm. Inside this branch mm is known to be a Float64.
  4. Delhi. Delhi is not in the table, so get returned its default, nothing. The test is true and the caller reports that there is no data.
  5. A caller that forgot. rainfall("Delhi") + 1 has no test. The nothing reaches +, and the run stopped with MethodError: no method matching +(::Nothing, ::Int64), far from the lookup that caused it.

The caller tests mm === nothing before it does arithmetic, and inside each branch the type is settled. The manual notes that the compiler generates efficient code for unions of a small number of types, up to 4 by default, so this costs almost nothing. something(x, default) returns the first argument that is not nothing, which suits a default. The try block shows what happens without a test: the nothing travelled to +, which threw MethodError: no method matching +(::Nothing, ::Int64), far from the lookup that produced it.

A small union is also cheap in an array. The last line stored 1,000 values, half of them missing: the Union{Missing, Float64} vector took 9,040 bytes, the floats in one block plus a byte per element for its type, while the Vector{Any} took 12,040 and kept every float as a separate object. That is how vectors with missing data stay fast, as the manual’s page on missing values describes.

Returning a sentinel number

Wrong: get(table, city, -1.0), so that a missing city looks like rainfall of −1 mm and is averaged in silently. Right: return nothing (or missing for a gap in data), so a caller that forgets to check fails at once instead of computing a wrong answer.

Checkpoint

Check: Union types for a result or nothing

  1. Question 1 of 2 Which values satisfy x isa Union{Int, Nothing}? Choose every one that does.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • nothing
    • 1

    A Union is the type of values that have one of its member types: here Int64 or Nothing.

  2. Question 2 of 2 rainfall(city) returns a Float64 or nothing. Which caller handles both results without throwing?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: mm = rainfall(city); mm === nothing ? "no data" : mm / 10

    Test for nothing before using the value, or supply a default with something(rainfall(city), 0.0) when a default is meaningful.

A type annotation asserts, restricts or converts, depending on where it stands

x::T looks the same everywhere, and does a different job in each of three places:

annotations.jl: the same :: in three places Julia · annotations.jl
# The same :: in three places does three different things.

# 1. In an expression it asserts: the value is returned, or a TypeError is thrown.
n = 3
println((n::Int) * 2)
try
    (2.5::Int) * 2
catch err
    println(sprint(showerror, err))
end

# 2. In a signature it restricts which arguments the method accepts.
half(x::Real) = x / 2
println(half(3), " ", half(3//4))
try
    half("3")
catch err
    println(first(split(sprint(showerror, err), '\n')))
end

# 3. On a local variable it converts every value assigned to it.
function running_total(xs)
    t::Float64 = 0
    for x in xs
        t += x
    end
    return t
end
println(running_total([1, 2, 3]))
try
    running_total([1, 2im])
catch err
    println(sprint(showerror, err))
end

# An annotation in a signature is not needed for speed: both methods are compiled for Int64 and infer Float64.
plain(x) = x / 2
println(only(Base.return_types(plain, (Int,))), " ", only(Base.return_types(half, (Int,))))
# An assertion helps where the compiler cannot know the type, such as a value read from a Vector{Any}.
first_plus(v) = v[1] + 1.0
first_plus_asserted(v) = (v[1]::Float64) + 1.0
println(only(Base.return_types(first_plus, (Vector{Any},))), " ", only(Base.return_types(first_plus_asserted, (Vector{Any},))))

# Two related types: the closest common supertype, and the type both values convert to.
println("typejoin(Int, Float64) = ", typejoin(Int, Float64), ", promote_type(Int, Float64) = ", promote_type(Int, Float64))
annotations.jl, run with Julia 1.13.1 Python · annotations_session.py
from session import files, run

# A type annotation in an expression, in a signature and on a local variable.
files("annotations.jl")
run("""
julia annotations.jl
""")

Output

$ julia annotations.jl
6
TypeError: in typeassert, expected Int64, got a value of type Float64
1.5 3//8
MethodError: no method matching half(::String)
6.0
InexactError: Float64(1.0 + 2.0im)
Float64 Float64
Any Float64
typejoin(Int, Float64) = Real, promote_type(Int, Float64) = Float64

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 annotations_session.py

Where ::Int stands What it does What went wrong in the run
In an expression, (n::Int) * 2 Asserts: returns the value, or throws (2.5::Int) threw TypeError: in typeassert
In a signature, half(x::Real) Restricts the arguments the method accepts half("3") threw a MethodError
On a local variable, t::Float64 = 0 Converts every value assigned to it t += 2im threw InexactError

A type assertion: The expression x::T, which returns x when x isa T and throws a TypeError otherwise. It documents an assumption and stops the program at the line where the assumption fails. in an expression checks a value and returns it unchanged, so it documents an assumption and stops the program where the assumption fails. In a signature the annotation decides which method a call may use, the subject of the lessons that follow. On a local variable it converts: running_total([1, 2, 3]) returned 6.0, a Float64, from integer inputs, and a complex value with a non-zero imaginary part could not be converted at all.

An annotation in a signature does not make a function faster. The style guide says a generic definition is not slower, because Julia compiles specialised versions as needed: plain(x) = x / 2 and half(x::Real) = x / 2 were both inferred to return Float64 for an Int64 argument. An assertion helps speed only where the compiler cannot know a type, as for a value read from a Vector{Any}: v[1] + 1.0 was inferred as Any, and (v[1]::Float64) + 1.0 as Float64, which the performance tips call annotating values taken from untyped locations. The last line separates two functions that relate types. typejoin(Int, Float64) is Real, the closest common supertype, while promote_type(Int, Float64) is Float64, the type both values are converted to for arithmetic; array literals such as [1, 2.5] use the second.

Checkpoint

Check: What a :: annotation does

  1. Question 1 of 2 In function f(xs) t::Float64 = 0; … end, what does ::Float64 do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It converts every value assigned to t to a Float64

    The same :: asserts in an expression, restricts in a signature and converts on a local variable.

  2. Question 2 of 2 What are typejoin(Int, Float64) and promote_type(Int, Float64)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Real and Float64

    typejoin climbs the tree to the narrowest shared ancestor; promote_type picks the type arithmetic converts both operands to.

Practise: walk the tree yourself

Find the concrete types below a type

Write concrete_below(T), which walks subtypes recursively and returns every concrete type below T, sorted by name. Check on your machine that it finds 12 types below Integer and 6 below Signed, and that Rational and Complex, which are neither abstract nor concrete, never appear in its result. Then write mean_or_nothing(v::AbstractVector{<:Real}), which returns nothing for an empty vector, and check what Base.return_types infers for it.

The self-check below goes through the tree, storage, invariance, unions and annotations, beginning wherever your answers so far were weakest; each choice carries the reason it is right or the slip behind it:

Practice that adapts to you

Practise this lesson

13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 What is supertype(Float64)?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: AbstractFloat

    supertype returns the direct parent in the tree: Float64 → AbstractFloat → Real → Number → Any.

  2. Question 2 of 13 A script that calls subtypes(Real) works in the REPL but stops with UndefVarError when run with julia script.jl. What fixes it?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: using InteractiveUtils at the top of the script

    subtypes and supertypes come from the InteractiveUtils standard library; supertype and <: are part of Base.

  3. Question 3 of 13 You want a type Celsius that every function written for Float64 accepts. Why does struct Celsius <: Float64 … end not work, and what is the usual design?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Concrete types are final, so declare Celsius under an abstract type such as Real and give it the methods it needs

    Values live only at the leaves, so a new type always hangs below an abstract type, and code that should accept it is written against that abstract type or left generic.

  4. Question 4 of 13 Why did one pass of total over the Vector{Real} allocate 16,000,000 bytes?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: Each element's type was found while the loop ran, so each s += x made a new object

    With an abstract element type the compiler cannot know which + to call, so the work happens one element at a time while the program runs, and each intermediate result is a new object.

  5. Question 5 of 13 A loader collects sensor values that are always floating-point numbers. Which container should it fill?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Float64[]

    Keep abstract types for signatures and concrete types for the element types of your data.

  6. Question 6 of 13 Why can a Vector{Int64} not be passed where a Vector{Real} is required?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Code may store any real number in a Vector{Real}, and a Vector{Int64} cannot hold 2.5

    Invariance protects the promise a container type makes about what can be stored in it, and the manual adds that the two types are laid out differently in memory.

  7. Question 7 of 13 What does Tuple{Int, Float64} <: Tuple{Real, Real} return?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: true

    Tuples follow the tree, as Tuple{Int} <: Tuple{Real} printed true; other parametric types do not.

  8. Question 8 of 13 Is Vector{<:Real} a concrete type?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: No: it is the set of all Vector types whose element type is below Real

    typeof never returns Vector{<:Real}; a value's type is one member, such as Vector{Int64}.

  9. Question 9 of 13 How does Julia print the type Union{Int, Nothing}?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Union{Nothing, Int64}

    A union is a set of types, so the order you write is not kept; the run printed Union{Nothing, Int64}.

  10. Question 10 of 13 The run stored 1,000 values in a Union{Missing, Float64} vector and summarysize counted 9,040 bytes. The values take 8 bytes each and the vector 40 bytes. How many bytes are left over for each element?

    Type a number.

    Show the answer: Question 10 of 13

    Answer: 1 bytes

    9,040 − 8,000 − 40 = 1,000 bytes for 1,000 elements: one byte each, which says whether the entry is a Float64 or missing.

  11. Question 11 of 13 A lookup returns -1.0 when a station has no data, and a monthly average comes out too low. What is the better design?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Return nothing (or missing in a dataset) and let callers handle it

    A Union{Nothing, Float64} result makes the missing case part of the type, so it cannot be mistaken for data.

  12. Question 12 of 13 What does (n::Int) * 2 return when n = 3?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: 6

    In an expression, x::T returns x when x isa T and throws a TypeError otherwise.

  13. Question 13 of 13 A colleague annotates every argument with ::Float64 "for speed". What happens?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: No speed-up, and the functions now refuse Int, Float32 and BigFloat arguments

    Annotate arguments for dispatch and for documentation, not for speed; plain(x) and half(x::Real) both inferred Float64 for an Int64 argument.

Interview questions

Warm-up (fresher to 2–5 years): how does an abstract type differ from a concrete type in Julia?

A concrete type is a type that values have: typeof always returns one, such as Int64, Float64 or Vector{Real}, and it fixes how a value is laid out in memory. An abstract type, such as Integer, Real or Number, has no values of its own; it groups types so that isa tests and method signatures can treat them alike. Concrete types are final: they can only have abstract supertypes, so values sit at the leaves of the type tree. A type can be concrete and still contain an abstract type: Vector{Real} is concrete, but each element is a pointer to a separately stored value, which made a million numbers take 16 MB instead of 8. Use abstract types in signatures and concrete element types in data.

Two design questions grow out of this lesson: a library function that rejects the data people pass it, and a lookup whose nothing crashes a batch job hours later. Write down what you would check first in each case, then open its first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to senior

    A library function is declared fit(x::Vector{Float64}, y::Vector{Float64}). Users report MethodErrors when they pass ranges, views, Float32 data and vectors of integers. How do you widen the signature, how far do you widen it, and what does the change cost?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    A station lookup returns nothing for unknown stations. A nightly batch job fails after three hours with MethodError: no method matching +(::Nothing, ::Float64) inside a summing loop. How do you find the cause, and how do you redesign the lookup and its callers so the failure comes early or never?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Every value has one concrete type; abstract types such as Integer and Real group types, are never instantiated, and are the only types that may have subtypes.
  • supertype, subtypes (from InteractiveUtils in a script) and <: walk the tree, and Bool <: Integer is why true + true is 2.
  • A Vector{Real} is a concrete type whose slots hold pointers to separate objects: 16,000,040 bytes for a million numbers against 8,000,040 for a Vector{Float64}, and 16,000,000 bytes allocated by one pass of a loop.
  • Type parameters are invariant, so Vector{Int} is not a Vector{Real}; write AbstractVector{<:Real} to accept every vector of reals, ranges and views included, at no cost in speed.
  • A Union such as Union{Nothing, Float64} types a result that may be absent, and the caller tests it with === nothing or supplies a default with something.
  • :: asserts in an expression, restricts in a signature and converts on a local variable; an annotation in a signature adds no speed, and an assertion helps only on a value from an untyped place such as a Vector{Any}.

Terms in this lesson

abstract type
A type that no value has directly, such as Integer, Real or Number. It groups other types in the type tree, so that isa tests and method signatures can treat them alike; only abstract types may have subtypes.
concrete type
A type that values can have, such as Int64, Float64 or Vector{Real}. typeof always returns one. Concrete types are final: they cannot have subtypes, so values sit at the leaves of the type tree.
invariant
Said of a parametric type whose parameters do not pass their subtype order on to it, as Julia's are: Int64 <: Real is true, but Vector{Int64} <: Vector{Real} is false. Tuple types are the exception.
subtype
A type that sits below another in the type tree. T <: S asks whether T is a subtype of S: Int64 <: Real is true, and every type is a subtype of itself and of Any.
type assertion
The expression x::T, which returns x when x isa T and throws a TypeError otherwise. It documents an assumption and stops the program at the line where the assumption fails.
Union
A type whose values have one of the listed member types: Union{Nothing, Float64} holds nothing or a Float64. The compiler handles unions of a few types efficiently, in code and in arrays.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.