Union Types
Union types declare that a value either has one type, or some other type. The
basic syntax for T.any
is:
T.any(SomeType, SomeOtherType, ...)
For example, T.any(Integer, String)
describes a type whose values can be
either Integer
or String
values, but no others.
class A
extend T::Sig
sig {params(x: T.any(Integer,String)).void}
def self.foo(x); end
end
# 10 and "Hello, world" both have type `T.any(Integer, String)`
A.foo(10)
A.foo("Hello, world")
# error: Expected `T.any(Integer, String)` but found `TrueClass`
A.foo(true)
Union types and flow-sensitivity
Given a value x
with a type like T.any(Integer, String)
, Sorbet will only
allow calls to methods that both types have in common, like this:
sig {params(x: T.any(Integer, String)).void}
def example(x)
# both `Integer` and `String` have a `to_s` method, so this is okay
puts(x.to_s)
end
But sometimes we want to be able to call a method that only exists on one of
those two types. For example, Integer
has an even?
method that doesn’t exist
on String
. If we didn’t do anything special, Sorbet would report an error:
sig {params(x: T.any(Integer, String)).void}
def example(x)
# ERROR: Method `even?` does not exist on `String` component of `T.any(Integer, String)`
x.even?
end
In situations like these, we have to first check whether x
is an Integer
or
not, and only then call the method:
sig {params(x: T.any(Integer, String)).void}
def example(x)
if x.is_a?(Integer)
x.even? # OK, because we checked with `is_a?`
end
end
Sorbet is smart enough to understand many different kinds of Ruby control flow
constructs (more than just if
statements and calls to is_a?
). Read the
flow-sensitive typing section for a deeper dive on this
topic.
Enumerations
Union types can be used to express enumerations. For example, if we have three
classes A
, B
, and C
, and would like to make one type that describes these
three cases, T.any(A, B, C)
is a good option:
class A; end
class B; end
class C;
extend T::Sig
sig {void}
def bar; end
end
class D
extend T::Sig
sig {params(x: T.any(A, B, C)).void}
def foo(x)
x.bar # error: method bar does not exist on A or B
case x
when A, B
T.reveal_type(x) # Revealed type: T.any(B, A)
else
T.reveal_type(x) # Revealed type: C
x.bar # OK, x is known to be an instance of C
end
end
end
In cases like this where the classes in the union don’t actually carry around any extra data, Sorbet has an even more convenient way to define enumerations. See Typed Enumerations via T::Enum.
Note that enumerations using primitive or literal types is not supported. For example, the following is not valid:
class A
extend T::Sig
# ERROR, intentionally unsupported
sig { params(input_param: T.any('foo', 'bar')).void }
def a(input_param)
puts input_param
end
end
T.nilable
and T::Boolean
T.nilable
and T::Boolean
are both defined in terms of T.any
:
T.nilable(x)
is a type constructor that will returnT.any(NilClass, x)
T::Boolean
is a type alias toT.any(TrueClass, FalseClass)
An effect of this implementation choice is that the same information propagation behavior outlined in Union types and flow sensitivity will take place for nilable types and booleans, as with any other union type:
class A
extend T::Sig
sig {params(x: T.nilable(T::Boolean)).void}
def foo(x)
if x.nil?
T.reveal_type(x) # Revealed type: NilClass
else
T.reveal_type(x) # Revealed type: T::Boolean
if x
T.reveal_type(x) # Revealed type: TrueClass
else
T.reveal_type(x) # Revealed type: FalseClass
end
end
end
end