Arrays, Hashes, and Generics in the Standard Library
The Sorbet syntax for type annotations representing arrays, hash maps, and other containers defined in the Ruby standard library looks different from other class types despite the fact that Ruby uses classes to represent these values, too. Here’s the syntax Sorbet uses:
Type | Example value |
---|---|
T::Array[Integer] | [1, 2, 3] |
T::Array[String] | ["hello", "goodbye"] |
T::Hash[Symbol, Integer] | {key: 0} |
T::Hash[String, Float] | {"key" => 0.0} |
T::Set[Integer] | Set[1, 2, 3] |
T::Range[Integer] | 0..10 |
T::Enumerable[Integer] | interface implemented by many types |
T::Enumerator[Integer] | [1, 2, 3].each |
T::Enumerator::Lazy[Integer] | [1, 2, 3].each.lazy |
T::Enumerator::Chain[Integer] | [1, 2].chain([3]) |
T::Class[Integer] | Integer |
T::
prefix?
Why the Sorbet uses syntax like MyClass[Elem]
for type arguments passed to
generic classes. All Sorbet type annotations are backwards
compatible with normal Ruby syntax, and this is no exception. In normal Ruby,
MyClass[Elem]
would correspond to a call to a method named []
defined on
MyClass
.
When creating user-defined generic classes, the sorbet-runtime
gem
automatically defines this method so that the type annotation syntax works at
runtime.
But for classes in the Ruby standard library, which Sorbet retroactively defined
as generic classes, the []
method will not be defined at runtime. One
potential option would have been to use sorbet-runtime
to monkey patch the
standard library so that the []
method is defined for generic classes, but
some of these Ruby standard library classes already define a meaningful []
method. For example:
Array[1, 2, 3]
# => evaluates to the array `[1, 2, 3]`
Set[1, 2, 3]
# => evaluates to the set containing 1, 2, and 3
Hash[:key1, 1, :key2, 2]
# => evaluates to the hash `{key1: 1, key2: 2}`
To avoid clobbering any existing []
method on these standard library classes,
Sorbet defines classes in the T::
namespace that mirror the names of classes
in the standard library.
Note that this mapping is not automatic: Sorbet has special-cased each individual class in the table above inside Sorbet (it is not possible to use merely prepend
T::
to the name of any class that has been defined as a generic type in an RBI file).
“Generic class without type arguments”
For backwards compatibility reasons during Sorbet’s original rollout, Sorbet sometimes allows generic classes defined in the Ruby standard library to appear in type annotations without being provided type arguments:
# typed: true
# ^^^^ important
T.let([], Array) # no error
versus:
# typed: strict
T.let([], Array)
# ^^^^^ error: Generic class without type arguments
When this happens, Sorbet defaults all missing type arguments to T.untyped
.
This exception is made only for classes in the Ruby standard library. For all
other generic classes, Sorbet requires that a generic class is provided all of
its required type arguments, even in # typed: false
files.
This behavior may change in the future, and we strongly discourage relying on it intentionally.
Generics and runtime checks
Recall that Sorbet is not only a static type checker, but also a system for validating types at runtime.
However, Sorbet completely erases generic type arguments at runtime. When
Sorbet sees a signature like T::Array[Integer]
, at runtime it will only
check whether an argument has class Array
, but not whether every element of
that array is also an Integer
at runtime.
This also means that if the element type of an array has type
T.untyped
, Sorbet will not report a static error, nor will
Sorbet report a runtime error.
sig {params(xs: T::Array[Integer]).void}
def foo(xs); end
untyped_str_array = T::Array[T.untyped].new('first', 'second')
foo(untyped_str_array)
# ^^^^^^^^^^^^^^^^^ no static error, AND no runtime error!
(Also note that unlike other languages that implement generics via type erasure, Sorbet does not insert runtime casts that preserve type safety at runtime.)
Another consequence of having erased generics is that things like this will not work:
sig {params(xs: T.any(T::Array[Integer], T::Array[String])).void}
def example(xs)
if xs.is_a?(T::Array[Integer]) # error!
# ...
elsif xs.is_a?(T::Array[String]) # error!
# ...
end
end
Sorbet will attempt to detect cases where it looks like this is happening and report a static error, but it cannot do so in all cases.
Note: Sorbet used to take these type arguments into account during runtime type-checking, but this turned out to be a common and difficult-to-debug source of performance problems, frequently turning a fast, constant-time algorithm (e.g.,
Hash
lookup) into a linear scan checking all element types.In order to verify that an array contained the values it claimed it did, the Sorbet runtime used to recursively check the type of every member of a collection, which would take a long time for arrays or hashes of a sufficiently large size. Consequently, this behavior has been removed.
Standard library generics and variance
Note that all the classes in the Ruby standard library that Sorbet knows about are covariant in their generic type members. Variance is is discussed in the docs for Generic Classes and Methods.
Implementing covariant classes in the Ruby standard library is a compromise. It means that things like this typecheck:
sig {returns(T::Array[Integer])}
def returns_ints; [1, 2, 3]; end
sig {params(xs: T::Array[T.any(Integer, String)]).void}
def takes_ints_or_strings(xs); end
xs = returns_ints
takes_ints_or_strings(xs) # no error
This makes it easy to get the most common Ruby usage patterns to type check without jumping through hoops.
However, having things like arrays and hash maps in Ruby be covariant means that the type checker wilfully says certain programs type check even when they have glaring type errors. For example:
xs = T.let([0], T::Array[Integer])
nil_xs = T.let(xs, T::Array[T.nilable(Integer)])
nil_xs[0] = nil
T.reveal_type(xs.fetch(0)) # type: Integer (!)
puts xs.fetch(0) # => nil (!)
In this example, we start with xs
having type T::Array[Integer]
. We then
upcast it to a T::Array[T.nilable(Integer)]
. This is the power of
covariance—this would not have been allowed had arrays been made invariant.
Sorbet allows nil_xs[0] = nil
because the type of nil_xs
says that it’s fine
for the array to contain nil
values.
But that’s a contradiction! nil_xs
and xs
are merely different names for the
same underlying storage. Later if someone were to go back and read the first
element of xs
, Sorbet would claim that they’re getting back an Integer
, but
in fact, they’d be getting back a nil
.
Sorbet is not the only type system to implement covariant arrays. Notably: TypeScript uses the same approach. This decision was largely motivated by getting as much Ruby code to typecheck when initially developing and rolling out Sorbet on large, existing codebases.
Enumerable
interface
Implementing the Here’s how to implement an enumerable class in Ruby with Sorbet:
class CountTo3
include Enumerable
# Must declare Elem type
extend T::Generic
Elem = type_member(:out) { {fixed: Integer} }
# Must implement abstract `Enumerable#each` method
sig do
override
.params(
blk: T.proc.params(arg0: Elem).returns(T.anything)
)
.void
end
def each(&blk)
yield 1
yield 2
yield 3
end
end
counter = CountTo3.new
total = counter.sum # calls Enumerable#sum
Sorbet treats the Enumerable
module in the standard library as an
abstract module and as a generic module. That
means that putting include Enumerable
inside a class requires defining two
things:
An
each
method which overrides the abstractEnumerable#each
method.The
each
method is what powers all the other methods that theEnumerable
module provides, likemap
,include?
,all?
, etc. Sorbet requires that implementations of abstract methods must have use theoverride
modifier in the signature.Sorbet includes an autocorrect to automatically generate empty implementations of any missing abstract methods—this is the easiest way to get started implementing an
each
method with the correct signature.For legacy reasons, the return type of the
Enumerable#each
method isT.untyped
and the return type of the block isBasicObject
. In new code, we recommend using.void
andT.anything
respectively (making this change inEnumerable#each
would be a breaking change, which we might consider making one day).A generic
type_member
calledElem
declaring what kinds of elements this class will enumerate. The class will need toextend T::Generic
to make thetype_member
method available.If the class will always enumerate values of a type which is known ahead of time, declare the elem using a
fixed
bound. TheCountTo3
example above uses afixed
bound because it always yieldsInteger
values.Otherwise, leave the bound unspecified. This approach is more suitable for implementing generic containers, like how
Array
andSet
are generic containers.Be sure to declare
Elem
as a covariant type member, usingtype_member(:out)
unless there’s a strong reason to do otherwise.For ease of migration in legacy codebases and gems which already have many custom
Enumerable
subclasses, Sorbet only requires declaring theElem
type_member
at# typed: strict
.
What’s next?
-
Every Ruby class and module doubles as a type in Sorbet. Class types supersede the notion some other languages have of “primitive” types. For example,
"abc"
is an instance of theString
class, and so"abc"
has typeString
. -
Types do not have to belong in the Ruby standard library to be declared as generic types. Read more about how to define custom generic classes and methods.