Sorbet

Sorbet

  • Get started
  • Docs
  • Try
  • Community
  • GitHub
  • Blog

›Type System

Getting Started

  • Overview
  • Adopting Sorbet
  • Tracking Adoption
  • Quick Reference
  • Visual Studio Code
  • TypeScript ↔ Sorbet

Static & Runtime

  • Gradual Type Checking
  • Enabling Static Checks
  • Enabling Runtime Checks
  • RBI Files
  • CLI Quickstart
  • CLI Reference
  • Runtime Configuration

Troubleshooting

  • Troubleshooting
  • Why type annotations?
  • FAQ
  • Error Reference
  • Unsupported Ruby Features

Type System

  • sig
  • Type Annotations (non-sig)
  • T.let, T.cast, T.must, T.bind
  • Class Types (Integer, String)
  • Arrays & Hashes
  • Nilable Types (T.nilable)
  • Union Types (T.any)
  • Flow-Sensitivity (is_a?, nil?)
  • T.type_alias
  • Exhaustiveness (T.absurd)
  • T::Struct
  • T::Enum
  • T.untyped
  • Blocks, Procs, & Lambdas
  • Abstract Classes & Interfaces
  • Final Methods & Classes
  • Override Checking
  • Sealed Classes
  • T.class_of
  • T.self_type
  • T.noreturn
  • T.anything
  • T.attached_class
  • Intersection Types (T.all)
  • Generics
  • T::NonForcingConstants
  • Banning untyped

Editor Features

  • Language Server (LSP)
  • Server Status
  • LSP & Typed Level
  • Go to Definition
  • Hover
  • Autocompletion
  • Find All References
  • Code Actions
  • Outline & Document Symbols
  • Documentation Comments
  • Suggesting sigs
  • Highlighting untyped
  • sorbet: URIs

Experimental Features

  • Tuples
  • Shapes
  • Overloads
  • Requiring Ancestors
  • RBS Comments
Edit

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:

TypeExample 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

Why the T:: prefix?

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.

Implementing the Enumerable interface

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:

  1. An each method which overrides the abstract Enumerable#each method.

    The each method is what powers all the other methods that the Enumerable module provides, like map, include?, all?, etc. Sorbet requires that implementations of abstract methods must have use the override 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 is T.untyped and the return type of the block is BasicObject. In new code, we recommend using .void and T.anything respectively (making this change in Enumerable#each would be a breaking change, which we might consider making one day).

  2. A generic type_member called Elem declaring what kinds of elements this class will enumerate. The class will need to extend T::Generic to make the type_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. The CountTo3 example above uses a fixed bound because it always yields Integer values.

    Note: to reiterate, simply including Enumerable in a class automatically makes that class generic, even if that class would not otherwise seem “generic.” Use the fixed annotation seen above in these cases.

    Otherwise, leave the bound unspecified. This approach is more suitable for implementing generic containers, like how Array and Set are generic containers.

    Be sure to declare Elem as a covariant type member, using type_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 the Elem type_member at # typed: strict.

What’s next?

  • Class Types

    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 the String class, and so "abc" has type String.

  • Generic Classes and Methods

    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.

← Class Types (Integer, String)Nilable Types (T.nilable) →
  • Why the T:: prefix?
  • "Generic class without type arguments"
  • Generics and runtime checks
  • Standard library generics and variance
  • Implementing the Enumerable interface
  • What's next?

Get started · Docs · Try · Community · Blog · Twitter