Intersection Types (T.all)
Intersection types are how we overlap two types, declaring that an expression
has the all properties of two or more types. The basic syntax for T.all
is:
T.all(Type1, Type2, ...)
Note that T.all
requires at least two arguments.
Unlike union types (T.any(Type1, Type2)
) which say that an expression is
either Type1
or the unrelated Type2
, T.all(Type1, Type2)
says that an
expression has both of these two types at the same time. Some common use cases
for intersection types:
- Overlapping two otherwise unrelated interfaces, like
T.all(Enumerable, Comparable)
. - Placing a constraint on a method’s generic type parameter, like
T.all(T.type_parameter(:U), Comparable)
. (For more, see Placing bounds on generic methods.) - Modeling control flow-sensitive type tests on certain kinds of values (discussed below).
As the names indicate, union types and intersection types can be understood by
analogy to the related operations on sets. Given Type1
and Type2
:
T.any(Type1, Type2)
is the union of the set of values in both types: values that are either ofType1
, or ofType2
.T.all(Type1, Type2)
is the intersection of the set of values in both types: values that are of bothType1
andType2
.
An example to make things more concrete:
# typed: true
extend T::Sig
module Type1
def method1; end
end
module Type2
def method2; end
end
class ExampleClass1
include Type1
include Type2
end
class ExampleClass2
include Type1
end
sig {params(x: T.all(Type1, Type2)).void}
def requires_both(x)
x.method1 # ok
x.method2 # also ok
end
requires_both(ExampleClass1.new) # ok
requires_both(ExampleClass2.new) # NOT ok, has Type1 but not Type2
Intersection types as ad hoc interfaces
Intersections are useful because they don’t require us to write an explicit interface that combines the requirements of two or more interfaces. For example, we might have imagined defining something like
module Type1AndType2
include Type1
include Type2
end
and then using Type1AndType2
instead of T.all(Type1, Type2)
.
This can sometimes work, but only if we retroactively modify ExampleClass1
to
be defined like this instead of including the interfaces individually:
class ExampleClass1
include Type1AndType2
end
When working with classes and interfaces defined in the standard library, it’s not possible to modify how a class was defined without resorting to monkey patching the class, which is undesirable.
In this light, T.all
can be understood as a way to define certain kinds of ad
hoc interfaces.
(Importantly, interfaces do not on their own provide support for “duck typing,” because the classes in question still have to implement each interface individually. The only thing that is ad hoc is the combination of two or more already-implemented interfaces.)
Understanding how intersection types collapse
In an attempt to both make error messages simpler and make Sorbet type check a codebase faster, Sorbet attempts to reduce or collapse intersection types to smaller types. For example:
class Parent; end
class Child < Parent; end
sig {params(x: T.all(Parent, Child)).void}
def example(x)
T.reveal_type(x) # => `Child`
end
In this example, Sorbet reveals that x
has type Child
, despite the method
being declared as taking T.all(Parent, Child)
.
Given what it knows about which classes are defined and their super classes,
Sorbet is smart enough to realize that every value with type Child
already
also has type Parent
via inheritance. That makes T.all(Child, Parent)
redundant, and it collapses the type to simply Child
.
Sorbet is quite smart at collapsing types like this. Some other examples of types that collapse:
# typed: true
extend T::Sig
class A; end
class B; end
module M; end
class FooParent; end
class FooChild < FooParent; end
class Bar; end
sig {params(xs: T::Array[T.all(A, B)]).void}
def example1(xs)
# Since A and B are unrelated classes, Sorbet notices that
# there are no values that satisfy `T.all(A, B)`, and thus
# collapses the type to `T.noreturn`
T.reveal_type(xs) # => T::Array[T.noreturn]
end
sig {params(x: T.all(A, M)).void}
def example2(x)
# Even though A and M are unrelated, because M is a module
# (not a class) the type does not collapse. Why? There might
# be some subclasses of A that include M, and some that don't.
#
# In this example, A has no subclasses. If we explicitly
# declare to Sorbet that A has no subclasses with `final!`,
# it would collapse the type.
T.reveal_type(x) # => T.all(A, M)
end
sig {params(x: T.all(FooParent, T.any(FooChild, Bar))).void}
def example3(x)
# Sorbet is smart enough to distribute over union types:
# T.all(FooParent, T.any(FooChild, Bar))
# => T.any(T.all(FooParent, FooChild), T.all(FooParent, Bar))
# => T.any(FooChild , T.noreturn )
# => FooChild
T.reveal_type(x) # => FooChild
end
All the examples above use normal, non-generic classes and modules, however the same principles govern when intersection types involving generic classes and modules collapse as well.
Intersection types and control flow
Without even realizing, you use intersection types almost every time you write code with Sorbet, indirectly via control flow-sensitive typing. For example:
class A; end
class B; end
# Note: `T.any` (either A or B)
sig {params(x: T.any(A, B)).void}
def example(x)
case x
when A
T.reveal_type(x) # => `A`
end
end
In this example, due to flow sensitive typing Sorbet understands that x
has
type A
within the when A
clause. But it doesn’t simply ascribe that type
to x
: it arrives at that type using intersection types. Inside the when
clause, Sorbet uses intersection types to say that x
has "both the type it
used to have and also A
": T.all(T.any(A, B), A)
.
But as we know from the previous section, this type collapses:
T.all(T.any(A, B), A)
=> T.any(T.all(A, A), T.all(B, A))
=> T.any(A , T.noreturn)
=> A
We can witness that this is the case by using a control flow type test on an unrelated module:
class A; end
module M; end
sig {params(x: A).void}
def example(x)
case x
when M
T.reveal_type(x) # => `T.all(A, M)`
end
end
In this example, the method signature said nothing about whether or not x
implements the module M
, but in the method body we ask at runtime whether it
does or not. This is enough to let Sorbet know that if the type test were to
pass at runtime and run the code inside the when
branch, that x
must now
have all the methods that were defined on A
and all the methods defined on
M
. (And as mentioned in the previous section, this type does not collapse
because A
is not final!
.)
What’s next?
-
Use of intersection types can lead to (sometimes unwanted)
T.noreturn
types. Read more about whatT.noreturn
represents, and when it is useful. Abstract Classes and Interfaces
Intersection types are most commonly used in tandem with interface modules. Read more about how to define composable abstract classes and interfaces.