Understanding the Julia Type System

By: Justyn Nissly

Re-posted from: https://blog.glcs.io/julia-type-system

In the programming landscape, type systems traditionally fall into two categories: static and dynamic.
In static languages such as C, C++, Rust, and Go,
computations are performed before run time to determine types and the values of those types.

Pearl, Ruby, PHP, Python, and JavaScript are languages that use a dynamic type system.
In these languages, nothing is known until runtime.
Now you may be thinking, where does Julia fit on this landscape? or
how does Julias type system work?
That is exactly what we will cover in this post!

Julias Type System

Where exactly does Julia fall along the type system landscape?
Is it static? Is it dynamic? Is it some strange amalgam of both?
According to Julia’s documentation,
it is dynamic, nominative, and parametric.
So, what does that mean?

It means Julia is a dynamic language but with a powerful twist.
You get all the features of a dynamic type system while also gaining some of the advantages of static type systems.
Julia does this by allowing you to indicate that certain values are of specific types.

By default, Julia will allow values to be of any type if the type is not explicitly stated.
This allows you to write functions without ever explicitly using types; however, explicitly declared types can be added as needed to help improve human readability and to help catch errors.

Now that we have the introduction out of the way, lets get our hands dirty and see how we use types in Julia.

Type Declarations

The first thing to get familiar with is how to declare types in Julia.
To declare a type (also called type annotations), we use the :: operator on a variable or expression in a program.
When the :: operator is applied to a variable, it is read as is an instance of.
For example, if you were to type var::Integer you would read this as var is an instance of Integer.

Lets look at an example in the REPL:

julia> test = (1+1)::Integer
2
julia> typeof(test)
Int64

You can see in the example above that
we declared the result of the expression (1+1) to be an instance of Integer
but when we checked the type using the typeof() function, it returns Int64.
The reason we see the type as Int64 rather than Integer is because Julia chooses a default primitive type (more on this later) for that value.
For integers, Julia will choose Int64 on a 64-bit computer or Int32 on a 32-bit computer.
Furthermore,
typeof(variable_name) will always return what is called a concrete type.
A concrete type is a type where values can be created by the compiler.
Variables can’t be an abstract type so any time you check the type of a variable you will get some concrete type.
In our example, Integer is an abstract type (we will cover these types later) that is a supertype of the concrete type Int64.
If you go to the REPL you will see that test isa Integer returns true even though using typeof(test) returns Int64.
This is because of the hierarchy of types in Julia. We will cover this later.

Adding types also works on function declarations:

julia> function multiply_two_numbers(x,y)::Float64
           return x*y
       end
multiply_two_numbers (generic function with 1 method)

Now let’s run this function with two integer values and see what gets returned:

julia> x = multiply_two_numbers(2,4)
8.0

julia> typeof(x)
Float64

You notice it returns a Float64 even though we passed it two integers?
This is because we have told Julia that the function should ALWAYS return a Float64 regardless of what the values in the function are.

One thing that we should note is that type annotations are generally not used much.
Using type annotations does not improve performance unless there is a “type-unstable” function, but we won’t cover that in this article. You can learn more about type-stability here and here.

Abstract Types

Abstract types function differently than primitive types. (More on those later.)
They function as placeholders for groups of related types.
They can’t be used to create objects,
but they are essential for organizing different types into a hierarchy.
Think of them as labels that help Julia understand which types are related to each other.

Lets look at an example from the Julia documentation.

Julias hierarchy for numeric values is actually built off of abstract types:

abstract type Number end
abstract type Real          <: Number end
abstract type AbstractFloat <: Real end
abstract type Integer       <: Real end
abstract type Signed        <: Integer end
abstract type Unsigned      <: Integer end

You can see that Number is an abstract type (a direct descendant of the Any type). Then Real is a subtype of Number,
Integer is a subtype of Real,
and Signed is a subtype of Integer.
That means the hierarchy looks like this:

Abstract Types Graphs

We can write a function to see the hierarchy of any type:

function show_type_tree(T, level=0)
    println("\t" ^ level, T)
    for t in subtypes(T)
        show_type_tree(t, level+1)
    end
end

Now you know that there is a hierarchy,
you might want to know what the point of it is.
Let’s look at our earlier example of the multiply_two_numbers() function:

julia> function multiply_two_numbers(x,y)::Float64
           return x*y
       end
multiply_two_numbers (generic function with 1 method)

Lets see what happens when with give the function two Integers:

julia> multiply_two_numbers(2::Int,2::Int)
4.0

Now lets try an integer and a float:

julia> multiply_two_numbers(2::Int,2.3::Float64)
4.6

One more example, we will do one as a float and one without asserting a type:

julia> multiply_two_numbers(1.6::Float64,54)
86.4

Notice how these examples still work without errors?
That is because of abstract types!
Because we created multiply_two_numbers() without declaring a type for the parameters,
Julia will default them to the Any type and that means we can assign any of the number types that are in the hierarchy as subtypes of Any

Let’s look at the hierarchy again to see how this works
Big Type Graph

Since we didnt explicitly define the type of the parameters for the function,
we can send it any type of numeric value and the function can work on it.
We are not limited to sending only Int64 or a Float64.
That is the real power of abstract types!
It allows us to create very generic code that works on various types within a category
without having to create a function for each type.

Primitive Types

Primitive types are similar to Abstract types in that they are part of a hierarchy of types,
but they are different in that they are concrete types.
The data contained in a primitive type is simply the bits in memory.
One interesting thing about primitive types in Julia is that Julia will let you define your own.

primitive type name bits end
primitive type name <: supertype bits end

Interestingly enough, Julias own default primitive types are all defined in Julia itself using the syntax above.

primitive type Float16 <: AbstractFloat 16 end
primitive type Float32 <: AbstractFloat 32 end
primitive type Float64 <: AbstractFloat 64 end

primitive type Bool <: Integer 8 end
primitive type Char <: AbstractChar 32 end

primitive type Int8    <: Signed 8 end
primitive type Int16   <: Signed 16 end
primitive type Int32   <: Signed 32 end
primitive type Int64   <: Signed 64 end
primitive type Int128  <: Signed 128 end

primitive type UInt8   <: Unsigned 8 end
primitive type UInt16  <: Unsigned 16 end
primitive type UInt32  <: Unsigned 32 end
primitive type UInt64  <: Unsigned 64 end
primitive type UInt128 <: Unsigned 128 end

Just like Abstract types, if you leave off a supertype when you declare the primitive,
the primitive will have Any as its direct supertype.
One thing to note when declaring your own primitive types, according to Julias documentation,
it is usually preferred that you wrap an existing primitive type in a new composite type rather than creating your own primitive.
Fortunately for us, this is a great segway into our next topic: Composite Types!

Composite Types

Composite types are Julia’s implementation of what other languages call structs, records, or objects.
Interestingly enough, composite types are declared very similar to structs in C

julia> struct Foo
           bar
           baz::Int
           qux::Float64
       end

Just like we saw earlier, if you leave the type annotation off of a field in your composite type, it will be defaulted to the Any type.

Just like structs in C or Objects in Java, composite types are a very powerful part of the language. We will have a more detailed post on composite types later, but if you would like to know more in the meantime, check out the official Julia documentation

Type of Types

In the last few sections we covered abstract types, primitive types, and we touched on composite types.
Let’s see what happens when we check the type of some of these types

julia> typeof(Number)
DataType

julia> typeof(Int64)
DataType

julia> typeof(AbstractFloat)
DataType

julia> typeof(Char)
DataType

You will notice that they all return the same thing, DataType.
The reason for this is the shared properties that these types have.
Abstract, primitive, and composite types all have names.
They have a supertype that is explicitly declared.
All those types are explicitly declared.
Since these properties are shared amongst the different types we covered,
Julia represents each instance of these types as the same concept internally.

Summary

In this post, we covered Abstract types, Primitive types, and Composite types.
These are the building blocks of the Julia type system.
You can see the power of the structure of Julia’s type system.
You can create generic code that works on various types within a category
without having to create a function for each type.
This simplifies your code and makes it easier to understand and maintain long-term.
If you want more in-depth information about types you can visit Julia’s official documentation to learn more.

Additional Links