SumTypes.jl

A julian implementation of sum types. Sum types, sometimes called 'tagged unions' are the type system equivalent of the disjoint union operation (which is not a union in the traditional sense). From a category theory perspective, sum types are interesting because they are dual to Tuples (whatever that means). In the Rust programming language, these are called "Enums".

At the end of the day, a sum type is really just a fancy word for a container that can store data of a few different, pre-declared types and is labelled by how it was instantiated.

Users of statically typed programming languages often prefer Sum types to unions because it makes type checking easier. In a dynamic language like julia, the benefit of these objects is less obvious, but perhaps someone can find a fun use case.

A common use-case for sum types is as a richer version of eums (enum in the julia sense, not in the Rust sense):

julia> @sum_type Fruit begin
           Apple()
           Banana()
           Orange()
       end

julia> Apple()
Fruit(Apple())

julia> Banana()
Fruit(Banana())

julia> Orange()
Fruit(Orange())

julia> typeof(Apple()) == typeof(Banana()) == typeof(Orange()) == Fruit
true

But this isn't particularly interesting. More intesting is sum types which can enclose data. Let's explore a very fundamental sum type (fundamental in the sense that all other sum types may be derived from it):

julia> using SumTypes

julia> @sum_type Either{A, B} begin
           Left{A}(::A)
           Right{B}(::B)
       end

This says that we have a sum type Either{A, B}, and it can hold a value that is either of type A or of type B. Either has two 'constructors' which we have called Left{A} and Right{B}. These exist essentially as a way to have instances of Either carry a record of how they were constructed by being wrapped in dummy structs named Left or Right.

Here is how these constructors behave:

julia> Left(1)
Either{Int64, Uninit}(Left{Int64}(1))

julia> Right(1.0)
Either{Uninit, Float64}(Right{Float64}(1.0))

Notice that because both Left{A} and Right{B} each carry one fewer type parameter than Either{A,B}, then simply writing Left(1) is not enough to fully specify the type of the full Either, so the unspecified field is SumTypes.Uninit by default.

In cases like this, you can rely on implicit conversion to get the fully initialized type. E.g.

julia> let x::Either{Int, Float64} = Left(1)
           x
       end
Either{Int64, Float64}(Left{Int64}(1))

Typically, you'll do this by enforcing a return type on a function:

julia> function foo()::Either{Int, Float64}
           # Randomly return either a Left(1) or a Right(2.0)
           rand(Bool) ? Left(1) : Right(2.0)
       end
foo (generic function with 1 method)

julia> foo()
Either{Int64, Float64}(Right{Float64}(2.0))

julia> foo()
Either{Int64, Float64}(Left{Int64}(1))

This is particularly useful because in this case foo is type stabe!

julia> Base.return_types(foo, Tuple{})
1-element Vector{Any}:
 Either{Int64, Float64}
 
julia> isconcretetype(Either{Int, Float64})
true

Note that unlike Union{A, B}, A <: Either{A,B} is false, and Either{A, A} is distinct from A.

Pattern matching on Sum types

Okay, that's nice but how do I actually access the data enclosed in a Fruit or an Either? The answer is pattern matching. SumTypes.jl exposes a @cases macro for efficiently unwrapping and branching on the contents of a sum type:

julia> myfruit = Orange()
Fruit(Orange())

julia> @cases myfruit begin
           Apple() => "Got an apple!"
           Orange() => "Got an orange!"
           Banana() => throw(error("I'm allergic to bananas!"))
       end
"Got an orange!"

julia> @cases Banana() begin
           Apple() => "Got an apple!"
           Orange() => "Got an orange!"
           Banana() => throw(error("I'm allergic to bananas!"))
       end
ERROR: I'm allergic to bananas!
[...]

@cases can automatically detect if you did't give an exhaustive set of cases (with no runtime penalty) and throw an error.

julia> @cases myfruit begin
           Apple() => "Got an apple!"
           Orange() => "Got an orange!"
       end
ERROR: Inexhaustive @cases specification. Got cases Union{Apple, Orange}, expected Union{Apple, Banana, Orange}
[...]

Furthermore, @cases can destructure sum types which hold data:

julia> let x::Either{Int, Float64} = rand(Bool) ? Left(1) : Right(2.0)
           @cases x begin
               Left(l) => l + 1.0
               Right(r) => r - 1
           end
       end
2.0

i.e. in this example, @cases took in an Either{Int,Float64} and if it contained a Left, it took the wrapped data (an Int) bound it do the variable l and added 1.0 to l, whereas if it was a Right, it took the Float64 and bound it to a variable r and subtracted 1 from r.