The Type System#

We’ve been using types since chapter three without ever looking directly at them. Time to fix that, because Julia’s type system is where the language’s two apparently contradictory promises — “easy like Python” and “fast like C” — turn out to be the same promise.

I’ll warn you up front: this chapter and the next are the conceptual heart of the book. If something doesn’t land immediately, that’s expected. Read it, write some code, come back.

The shape of the hierarchy#

Every value in Julia has exactly one concrete type. Concrete types can be instantiated, have a defined memory layout, and have no subtypes:

typeof(42)          # Int64
typeof(3.14)        # Float64
typeof("hello")     # String

Above them sit abstract types, which organize concrete types into a tree. Abstract types cannot be instantiated — you never hold a Real in your hand, you hold an Int64 that is a Real. Their entire job is to let you write code that applies to a whole family at once.

julia> Int64 <: Integer <: Real <: Number <: Any
true

<: reads “is a subtype of.” A slice of the standard numeric tower looks like this:

Any
└─ Number
   └─ Real
      ├─ Integer
      │  ├─ Signed   → Int8, Int16, Int32, Int64, Int128, BigInt
      │  ├─ Unsigned → UInt8 … UInt128
      │  └─ Bool
      ├─ AbstractFloat → Float16, Float32, Float64, BigFloat
      ├─ Rational
      └─ AbstractIrrational

You can explore this yourself, which I’d encourage — it’s more instructive than any diagram I can draw:

supertype(Int64)         # Signed
subtypes(Integer)        # [Bool, Signed, Unsigned]
Float64 <: Real          # true
Float64 <: Integer       # false

Any sits at the top. Every type is a subtype of Any, and an unannotated function argument is implicitly ::Any.

Why “abstract” and “concrete” is the distinction that matters#

Here’s the practical payoff of that vocabulary, and it’s worth stating bluntly:

  • Annotate function arguments with abstract types. f(x::Real) accepts anything real, and Julia compiles a fast specialized version for each concrete type you actually pass.

  • Give struct fields and array elements concrete types. Vector{Float64} is a contiguous block of floats. Vector{Real} is a block of pointers to floats scattered on the heap, because the compiler doesn’t know how big a Real is.

Same word, opposite advice, depending on where it appears. Getting this backwards is the single most common performance mistake beginners make in Julia. Abstract in signatures, concrete in storage.

Defining your own types#

A struct groups related fields into a new type:

struct LedgerEntry
    account::String
    amount::Float64
    posted::Bool
end

Julia gives you a constructor for free:

julia> e = LedgerEntry("GL-4001", 1200.50, true)
LedgerEntry("GL-4001", 1200.5, true)

julia> e.account
"GL-4001"

julia> e.amount
1200.5

Because every field has a concrete type, Julia knows this struct’s exact memory layout: 8 bytes for a pointer to the string, 8 for the float, 1 for the bool. An array of a million of these is a tight, contiguous block — the same thing a C struct array would give you.

If you leave fields unannotated:

struct SlowEntry
    account
    amount
    posted
end

…it still works, but every field is Any, every field is a pointer, and you’ve thrown away the performance. Annotate your struct fields. This is the one place where being explicit about types is unambiguously worth it.

Structs are immutable by default#

julia> e.amount = 999.0
ERROR: setfield!: immutable struct of type LedgerEntry cannot be changed

This is deliberate, and it’s the right default. Immutable values can be stored inline in arrays, passed in registers, and freely shared without anyone worrying about who might modify them. They can’t create the class of bug where a function you called quietly changed your data.

When you genuinely need mutation, ask for it:

mutable struct Account
    id::String
    balance::Float64
end

a = Account("GL-4001", 1200.0)
a.balance += 500.0        # fine

Start immutable. Reach for mutable struct when you have a specific reason — an object with genuinely changing state over its lifetime, like a running accumulator or a connection handle. We’ll dig into the consequences of mutability in its own chapter.

Note

Redefining structs no longer requires a restart For years, one of Julia’s genuine annoyances was that changing a struct definition meant restarting your entire session — which, given compile latency, was painful during development.

As of Julia 1.12 this is fixed. Types can be redefined in a running session, thanks to work on the “world age” mechanism. Combined with Revise.jl (which auto-reloads your code as you edit it), the interactive development loop is dramatically better than it was on the 1.10 LTS. If you’ve bounced off Julia before because of this, it’s worth another look.

Constructors#

The default constructor takes all fields in order. You’ll often want something friendlier — an outer constructor is just a function with the same name as the type:

struct LedgerEntry
    account::String
    amount::Float64
    posted::Bool
end

# Convenience: entries default to unposted
LedgerEntry(account, amount) = LedgerEntry(account, amount, false)

When you need to validate before the object exists, use an inner constructor, which lives inside the struct and calls new:

struct Percentage
    value::Float64

    function Percentage(v)
        0 <= v <= 100 || throw(ArgumentError("percentage must be in 0..100, got $v"))
        return new(v)
    end
end
julia> Percentage(45.0)
Percentage(45.0)

julia> Percentage(150.0)
ERROR: ArgumentError: percentage must be in 0..100, got 150.0

Defining an inner constructor replaces the default one, which is exactly what you want here — now it’s impossible to construct an invalid Percentage anywhere in your program. That’s a strong guarantee for six lines of code.

Parametric types#

Here’s where the type system gets genuinely powerful. A type can take another type as a parameter:

struct Measurement{T<:Real}
    value::T
    uncertainty::T
end

Measurement{Float64} and Measurement{Int} are now two different concrete types, generated from one definition:

julia> m = Measurement(9.81, 0.02)
Measurement{Float64}(9.81, 0.02)

julia> typeof(m)
Measurement{Float64}

The T<:Real constraint says “T can be any subtype of Real,” so Measurement{String} is rejected at construction time.

You’ve been using parametric types all along without naming them. Vector{Float64}, Dict{String, Int}, Rational{Int64} — all the same mechanism. This is how Julia gets containers that are both generic (write Vector once) and fast (Vector{Float64} has a concrete layout).

A subtlety worth knowing early#

Vector{Float64} <: Vector{Real}      # false!

This surprises everyone. Float64 <: Real is true, so shouldn’t a vector of floats be a vector of reals? No — and for a good reason. If it were, you could take a Vector{Float64}, treat it as a Vector{Real}, and push a BigInt into it, breaking the memory layout the compiler is relying on.

When you want “a vector of any real type,” write it like this:

function total(xs::Vector{<:Real})
    ...
end

Vector{<:Real} means “a Vector{T} for some T that is a subtype of Real.” That accepts Vector{Float64} and Vector{Int} while keeping each one’s concrete layout intact.

Better still, in most cases, don’t annotate at all — or annotate the abstract container:

function total(xs::AbstractVector)
    ...
end

AbstractVector also accepts views, ranges, and the many specialized array types in the ecosystem. Writing against the abstract type is how your function ends up working with people’s data structures you’ve never heard of.

Defining your own abstract types#

You can extend the hierarchy yourself:

abstract type Instrument end

struct Bond <: Instrument
    face_value::Float64
    coupon::Float64
end

struct Equity <: Instrument
    shares::Int
    price::Float64
end

Now Bond and Equity are both Instruments, and you can write functions that accept any Instrument:

value(b::Bond) = b.face_value
value(e::Equity) = e.shares * e.price

portfolio_value(xs::Vector{<:Instrument}) = sum(value, xs)

Note what that abstract type does and doesn’t do. It does not carry fields, methods, or implementation — Julia has no inheritance of data or behaviour. It is purely a name in the tree that says “these things belong to the same family and can be handled together.”

That’s a smaller feature than a base class in Java or Python, and deliberately so. What fills the gap — and what makes this design work — is the subject of the next chapter.

Useful type introspection#

Habits worth building for exploring unfamiliar code:

typeof(x)              # the concrete type of a value
eltype(collection)     # the element type
fieldnames(LedgerEntry)   # (:account, :amount, :posted)
isa(x, Real)           # is x a Real?
isconcretetype(Float64)   # true
isbitstype(Float64)    # true — plain data, stored inline, no pointers

isbitstype is a nice one to know. A type is “bits” if it’s plain immutable data with no references — which means arrays of it are dense, it can live in registers, and it can be sent to a GPU. Float64 is. LedgerEntry isn’t, because it contains a String (a pointer). That’s not a problem, just a thing to be aware of when you’re chasing performance.

Try it yourself#

  1. Define an immutable Invoice struct with a customer name, an amount, and a due date (use a String for now). Construct one.

  2. Add an outer constructor that defaults the due date to "NET30".

  3. Add an inner constructor that rejects negative amounts.

  4. Make it parametric so the amount can be any Real, then check that Invoice(…, 1200, …) and Invoice(…, 1200.0, …) produce different concrete types.

Solutions#

# 1–3 combined
struct Invoice
    customer::String
    amount::Float64
    due::String

    function Invoice(customer, amount, due)
        amount >= 0 || throw(ArgumentError("amount must be non-negative"))
        return new(customer, amount, due)
    end
end

Invoice(customer, amount) = Invoice(customer, amount, "NET30")

# 4.
struct PInvoice{T<:Real}
    customer::String
    amount::T
    due::String
end

typeof(PInvoice("Acme", 1200, "NET30"))     # PInvoice{Int64}
typeof(PInvoice("Acme", 1200.0, "NET30"))   # PInvoice{Float64}

You now know how Julia organizes types and how to add your own to the hierarchy. Next: how Julia decides which function to run — the idea everything else in the language is built around.