Crystal allows for a variable to consist of multiple types. This is called a union type. In Crystal, it is common for a union type to be inferred by the compiler.
A union type is still a single type at runtime, even if it consists of multiple types.
This means that if a union type is built of String
and Int32
, it will not be both at the same time.
Instead, it will be either a String
or an Int32
.
A union type is declared by separating the types with a pipe (|
).
They are often placed in parenthesis, but it is not required.
The most common union type is (T | Nil)
where T
is a type, which can occur in methods that can return Nil
.
This is also known as a variable being nilable.
foo : String = "Hello"
foo = nil # Error: type must be String, not (String | Nil)
foo : (String | Nil) = "Hello"
foo = nil
It is not limited to just two types, but can be as many as you want.
foo : (String | Int32 | Nil | Float64) = "Hello"
foo = 1
foo = nil
foo = 1.0
typeof
vs Object#class
There are two ways to get the type of a variable.
Either by using typeof
or by using Object#class
.
The difference is that typeof
will return a variable's type at compile time, while Object#class
will return the type at runtime.
This means that if you want to see if a variable is a union type, for example, Object#class
will not be able to tell you that as it will only return the type at runtime, which is a single type.
foo = 0 == 0 ? "a" : 1
typeof(foo) # => (String | Int32)
foo.class # => String
As a union type is a single type at runtime, all the standard operations work on it. But when compiling the code the compiler will need to know which type it is. Thereby the code has to be setup in such a way that it can only be one of the types when wanting to use the type-specific operations.
foo : (String | Int32) = "Hello"
foo.downcase # Error: undefined method 'downcase' for (String | Int32)
Crystal does have a particular method for union types: the is_a?
method, which takes a type as an argument and returns a boolean.
The nil?
method is a shortcut for is_a?(Nil)
.
Putting the is_a?
method in a control expression will tell the compiler which type it is and thereby guarantee that it is that type.
And for an else branch it will be guaranteed that it is not that type.
foo : (String | Int32) = "Hello"
if foo.is_a?(String)
 typeof(foo)  # => String
 foo.downcase # => "hello"
end
This is_a?
is not limited to having a single type as an argument but can also have a union type.
It can also be combined with &&
for multiple types.
The is_a?
method when using it in conjunction with a control expression can't be an instance variable or class variable.
Instead these have to be assigned to a local variable first.
One way of making a union type into a single type is by making it so that a branch can only be entered if the type is a specific type.
Another approach is to use the as
method.
This will make an union type into a single type by doing a runtime check.
An exception will be raised if the type is not the expected type.
foo : String | Int32 = "Hello"
foo.as(String).downcase # => "hello"
foo.as(Int32) # Error: can't cast String to Int32
This approach is only meant for when you are sure that the type is the expected type or if you want to raise an exception when it is not.
Using this approach with an improper setup can lead to unexpected behavior.
as?
works very similarly to as
, but it will return' nil' instead of raising an exception if the type is not the expected type.
This means it will return a union type of the expected type and Nil
.
foo : (String | Int32) = "Hello"
foo.as?(String).downcase # => "hello"
foo.as?(Int32) # => nil
Nilable means a variable can be either a type or Nil
.
This can be written as (T | Nil)
.
But since Nilable types are relatively common, there is a shorthand for it: T?
.
# This:
foo : (String | Nil) = "Hello"
foo = nil
# Is the same as:
foo : String? = "Hello"
foo = nil
The compiler will infer a union type if it is not sure which type it is.
For example, in the following code the compiler will not know which type foo
is since it can be either a String
or an Int32
.
if true
 foo = 1
else
 foo = "Hello"
end
typeof(foo) # => (Int32 | String)
This inference happens automatically, and there are other scenarios where, for example, the compiler will infer that a method returns a union type.
character = "Hello world"[10]?
typeof(character) # => (Char | Nil)