id | title | sidebar_label |
---|---|---|
stdlib-generics |
Arrays, Hashes, and Generics in the Standard Library |
Arrays & Hashes |
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 |
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 that Sorbet retroactively defined
as generic classes, the []
method will not always 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).
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.
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.
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.
-
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.