Skip to content

Latest commit

 

History

History
1153 lines (895 loc) · 50.6 KB

generics.md

File metadata and controls

1153 lines (895 loc) · 50.6 KB
id title sidebar_label
generics
Generic Classes and Methods
Generics

Sorbet has syntax for creating generic methods, classes, and interfaces.

How to use generics well

Despite many improvements made to Sorbet's support for generics over the years, it is unfortunately easy to both:

  1. Use generics incorrectly, and not be told as much by Sorbet
  2. Use generics "correctly," only to realize that the abstractions you've built are not easy to use.

It is therefore important to thoroughly test abstractions making use of Sorbet generics.

The tests you'll need to write look materially different from other Ruby tests you may be accustomed to writing, because the tests need to deal with what code should or should not typecheck, rather than what code should or should not run correctly.

  • Sometimes, something that shouldn't type check does type check anyways.

    This is bad because the generic abstraction being built will not necessarily provide the guarantees it should. This can give users of the generic abstraction false confidence in the type system.

    To mitigate this, write example code that should not type check and double check that it doesn't. Get creative with these tests. Consider writing tests that make uncommon use of subtyping, inheritance, mutation, etc.

    (There is nothing built into Sorbet for writing such tests. The easiest approach is to manually build small examples using the new API that don't type check, but don't check the resulting files in (or check them in, but mark them # typed: ignore, and bump the sigil up temporarily while making changes). The diligent way to automate this is by running Sorbet a second time on a codebase that includes extra files meant to not type check, asserting that Sorbet indeed reports errors.)

    It can also be helpful to use T.reveal_type and/or T.assert_type! to inspect the types of intermediate values to see if T.untyped has silently snuck in somewhere. If T.untyped has snuck into the implementation somewhere, things will type check, but it won't mean much.

  • Sometimes, something doesn't type check when it should.

    Most of these kinds of bugs in Sorbet were fixed as of July 2022, but some remain.

    These kinds of problems are bad because they cause confusion and frustration for people attempting to use the generic abstraction, not for the person who implemented the abstraction. This is especially painful for those who are new to Sorbet (or even Ruby), as well as those those who are not intimately familiar with the limitations of Sorbet's generics.

    To mitigate this, "test drive" the abstraction being built. Don't assume that if the implementation type checks that it will work for downstream users. Get creative and write code as a user would.

    Encountering these errors is not only frustrating for you, but also frustrating for others, and incurs a real risk of making people's first experience with Sorbet unduly negative.

By avoiding both of these kinds of outcomes, you will be able to build generic abstractions that work better overall.

As with all bugs in Sorbet, when you encounter them please report them. See the list of known bugs here:

Generics milestone

Basic syntax

The basic syntax for class generics in Sorbet looks like this:

# typed: strict

class Box
  extend T::Sig
  extend T::Generic # Provides `type_member` helper

  Elem = type_member # Makes the `Box` class generic

  # References the class-level generic `Elem`
  sig {params(val: Elem).void}
  def initialize(val:); @val = val; end
  sig {returns(Elem)}
  def val; @val; end
  sig {params(val: Elem).returns(Elem)}
  def val=(val); @val = val; end
end

int_box = Box[Integer].new(val: 0)
T.reveal_type(int_box) # `Box[Integer]`

T.reveal_type(int_box.val) # `Integer`

int_box.val += 1

→ View on sorbet.run

The basic syntax for function generics in Sorbet looks like this:

# typed: true
extend T::Sig

sig do
  # `extend T::Generic` is not required just to use `type_parameters`
  type_parameters(:U)
    .params(
      # The block can return any value, and the type of
      # that value defines type_parameter(:U)
      blk: T.proc.returns(T.type_parameter(:U))
    )
    # The method returns whatever the block returns
    .returns(T.type_parameter(:U))
end
def with_timer(&blk)
  start = Time.now
  res = yield
  duration = Time.now - start
  puts "Running block took #{duration.round(1)}s"
  res
end

res = with_timer do
  sleep 2
  puts 'hello, world!'
  # Block returns an Integer
  123
end
# ... therefore the method returns an Integer
T.reveal_type(res) # `Integer`

→ View on sorbet.run

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 types at runtime, both for classes and methods. When Sorbet sees a signature like Box[Integer], at runtime it will only check whether an argument has class Box (or a subtype of Box), but nothing about the types that argument has been applied to. Generic types are only checked statically. Similarly, if Sorbet sees a signature like

sig do
  type_parameters(:U)
    .params(
      x: T.type_parameter(:U),
      y: T.type_parameter(:U),
    )
    .void
end
def foo(x, y); end

Sorbet will not check that x and y are the same class at runtime.

Since generics are only checked statically, this removes using tests as a way to guard against misuses of T.untyped. For example, Sorbet will neither report a static error nor a runtime error on this example:

sig {params(xs: Box[Integer]).void}
def foo(xs); end

untyped_box = Box[T.untyped].new(val: 'not an int')
foo(untyped_box)
#   ^^^^^^^^^^^ no static error, AND no runtime error!

Another consequence of having erased generics is that things like this will not work:

if box.is_a?(Box[Integer]) # error!
  # do something when `box` contains an Integer
elsif box.is_a?(Box[String]) # error!
  # do something when `box` contains a String
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.

The workaround is to check only the class type of the generic class, and check any element type before it's used:

if box.is_a?(Box)
  val = box.val
  if val.is_a?(Integer)
    # ...
  elsif val.is_a?(String)
    # ...
  end
end

Reifying generics at runtime

This section discusses features like fixed and type_template which are introduced further below.

Sorbet erases generic types at runtime, but with abstract methods and T::Class it's sometimes possible to reify those types. For example, commonly we might want the generic type so we can use it to instantiate a value of that type:

class Factory
  extend T::Generic

  InstanceType = type_template

  sig {returns(InstanceType)}
  def self.make_bad
    InstanceType.new
    # ^ this is not valid, because `InstanceType`
    # is a generic type (erased at runtime)
  end
end

The problem here is that InstanceType does not "store" the runtime type that might be bound at runtime--it's only there for the purposes of static checking.

To fix this, we can just add an abstract method to our interface which forces subclasses to reify the generic:

class Factory
  extend T::Generic
  abstract!

  InstanceType = type_template

  # (1) Require the user to provide a type
  sig { abstract.returns(T::Class[InstanceType]) }
  def self.instance_type; end

  sig {returns(InstanceType)}
  def self.make
    # (2) Call `self.instance_type.new` instead of `InstanceType.new`
    self.instance_type.new
  end
end

class A; end

class AFactory < Factory
  # (3) Fix the type in the child
  InstanceType = type_template { {fixed: A} }

  # (4) Provide the type explicitly. Sorbet checks that `A` is a valid value of
  #     type T::Class[A]
  sig { override.returns(T::Class[InstanceType]) }
  def self.instance_type = A
end

Some notes:

  1. We declare an abstract method that uses T::Class[InstanceType] for its return type. Subclasses are forced to fill this in (or remain abstract).
  2. The parent Factory class can call that method in make to access the class at runtime. Unlike InstanceType.new, this actually produces the correct class at runtime.
  3. In the subclass, we're forced to redeclare the type template. In this sense, the type_template acts like a sort of "abstract type". This is done with the fixed annotation.
  4. The subclass implements instance_type with A. Sorbet checks that the implementation instance_type and the value provided to InstanceType remain in sync because of the T::Class[InstanceType] return annotation.

This approach works as long as we only want to reify singleton class types. More complex types, like T.any types or types representing instance classes (not singleton class types) will either not work at all, or require a little more ingenuity.

type_member & type_template

The type_member and type_template annotations declare class-level generic type variables.

class A
  X = type_member
  Y = type_template
end

Type variables, like normal Ruby variables, have a scope:

  • The scope of a type_member is all instance methods on the given class. They are most commonly used for generic container classes, because each instance of the class may have a separate type substituted for the type variable.

  • The scope of a type_template is all singleton class methods on the given class. Since a class only has one singleton class, type_template variables are usually used as a way for an abstract parent class to require a concrete child class to pick a specific type that all instances agree on.

One way to think about it is that type_template is merely a shorter name for something which could have also been named singleton_class_type_member. In Sorbet's implementation, type_member and type_template are treated almost exactly the same.

Note that this means that it's not possible to refer to a type_template variable from an instance method. For a workaround, see the docs for error code 5072.

:in, :out, and variance

Understanding variance is important for understanding how type_member's and type_template's behave. Variance is a type system concept that controls how generics interact with subtyping. Specifically, from Wikipedia:

"Variance refers to how subtyping between more complex types relates to subtyping between their components."

Variance is a property of each type_member and type_template (not the generic class itself, because generic classes may have more than one such type variable). There are three kinds of variance relationships:

  • invariant (subtyping relationships are ignored for this type variable)
  • covariant (subtyping order is preserved for this type variable)
  • contravariant (subtyping order is reversed for this type variable)

Here is the syntax Sorbet uses for these concepts:

module Example
  # invariant type member
  X = type_member

  # covariant type member
  Y = type_member(:out)

  # contravariant type member
  Z = type_member(:in)
end

In this example, we would say:

  • "Example is invariant in X",
  • "Example is covariant in Y", and
  • "Example is contravariant in Z", and

For those who have never encountered variance in a type system before, it may be useful to skip down to Why does tracking variance matter?, which motivates why type systems (Sorbet included) place such emphasis on variance.

Invariance

(For convenience throughout these docs, we use the annotation A <: B to claim that A is a subtype of B.)

By default, type_member's and type_template's are invariant. Here is an example of what that means:

class Box
  extend T::Generic
  # no variance annotation, so invariant by default
  Elem = type_member
end

int_box = Box[Integer].new

# Integer <: Numeric, because Integer inherits from Numeric, however:
T.let(int_box, Box[Numeric])
# ^ error: Argument does not have asserted type

# Elem is invariant, so the claim
#   Box[Integer] <: Box[Numeric]
# is not true

→ View on sorbet.run

Since Elem is invariant (has no explicit variance annotation), Sorbet reports an error on the T.let attempting to widen the type of int_box to Box[Numeric]. Two objects of a given generic class with an invariant type member (Box in this example) are only subtypes if the types bound to their invariant type_member's are equivalent.

Invariant type_member's and type_template's, unlike covariant and contravariant ones, may be used in both input and output positions within method signatures. This nuance is explained in more detail in the next sections about covariance and contravariance.

Covariance (:out)

Covariant type variables preserve the subtyping relationship. Specifically, if the type Child is a subtype of the type Parent, then the type M[Child] is a subtype of the type M[Parent] if the type member of M is covariant. In symbols:

Child <: Parent  ==>  M[Child] <: M[Parent]

Note that only a Ruby module (not a class) may have a covariant type_member. (See the docs for error code 5016 for more information.) Note that since type_template creates a type variable scoped to a singleton class, type_template can never be covariant (because all singleton classes are classes, even singleton classes of modules).

Here's an example of a module that has a covariant type member:

extend T::Sig

# covariant `Box` interface
module IBox
  extend T::Generic
  # `:out` declares this type member as covariant
  Elem = type_member(:out)
end

sig {params(int_box: IBox[Integer]).void}
def example(int_box)
  T.let(int_box, IBox[Numeric]) # OK
end

In this case, the T.let assertion reports no static errors because Integer <: Numeric, and therefore IBox[Integer] <: IBox[Numeric].

Covariant type members may only appear in output positions (thus the :out annotation). For more information about what an output position is, see Input and output positions below.

In practice, covariant type members are predominantly useful for creating interfaces that produce values of the specified type. For example:

module IBox
  extend T::Sig
  extend T::Generic
  abstract!

  # Covariant type member
  Elem = type_member(:out)

  # Elem can only be used in output position
  sig {abstract.returns(Elem)}
  def value; end
end

class Box
  extend T::Sig
  extend T::Generic

  # Implement the `IBox` interface
  include IBox

  # Redeclare the type member, to be compatible with `IBox`
  Elem = type_member

  # Within this class, `Elem` is invariant, so it can also be used in the input position
  sig {params(value: Elem).void}
  def initialize(value:); @value = value; end

  # Implement the `value` method from `IBox`
  sig {override.returns(Elem)}
  def value; @value; end

  # Add the ability to update the value
  # (allowed because `Elem` is invariant within this class)
  sig {params(value: Elem).returns(Elem)}
  def value=(value); @value = value; end
end

→ View on sorbet.run

Note how in the above example, Box includes IBox, meaning that Box is a child of IBox. Children of generic classes or modules must always redeclare any type members declared by the parent, in the same order. The child must either copy the parent's specified variance or redeclare it as invariant. When the child is a class (not a module), redeclaring it as invariant is the only option.

Contravariance (:in)

Contravariant type parameters reverse the subtyping relationship. Specifically, if the type Child is a subtype of the type Parent, then type M[Parent] is a subtype of the type M[Child] if the type member of M is contravariant. In symbols:

Child <: Parent  ==>  M[Parent] <: M[Child]

Contravariance is quite unintuitive for most people. Luckily, contravariance is not unique to Sorbet—all type systems that have both subtyping relationships and generics must grapple with variance, contravariance included, so there is a lot written about it elsewhere online. It maybe even be helpful to read about contravariance in a language you already have extensive familiarity with, as many of the concepts will transfer to Sorbet.

The way to understand contravariance is by understanding which function types are subtypes of other function types. For example:

sig do
  params(
    f: T.proc.params(x: Child).void
  )
  .void
end
def takes_func(f)
  f.call(Child.new)
  f.call(GrandChild.new)
end

wants_at_least_parent = T.let(
  ->(parent) {parent.on_parent},
  T.proc.params(parent: Parent).void
)
takes_func(wants_at_least_parent) # OK

wants_at_least_child = T.let(
  ->(child) {child.on_child},
  T.proc.params(child: Child).void
)
takes_func(wants_at_least_child) # OK

wants_at_least_grandchild = T.let(
  ->(grandchild) {grandchild.on_grandchild},
  T.proc.params(grandchild: GrandChild).void
)
takes_func(wants_at_least_grandchild) # error!

→ View full example on sorbet.run

In this example, takes_func requests that it be given an argument f that, when called, can be given Child instances. As we see in the method body of takes_func, it's valid to call f on both Child and GrandChild instances (class GrandChild < Child, so all GrandChild instances are also Child instances).

At the call site, both wants_at_least_child and wants_at_least_parent satisfy the contract that takes_func is asking for. In particular, the wants_at_least_parent is fine being given any instance, as long as it's okay to call parent.on_parent (because of inheritance, both Child and GrandChild have this method). Since takes_func guarantees that it will always provide a Child instance, the thing provided will always have an on_parent method defined.

For that reason, Sorbet is okay treating T.proc.params(parent: Parent).void as a subtype of T.proc.params(child: Child).void, even though Child is a subtype of Parent.

Meanwhile, it's not okay to call takes_func(wants_at_least_grandchild), because sometimes takes_func will only provide a Child instance, which would not have the on_grandchild method available to call (which is being called inside the wants_at_least_grandchild function).

When it comes to user-defined generic classes using contravariant type members, the cases where this is useful is usually building generic abstractions that are "function like." For example, maybe a generic task-processing abstraction:

module ITask
  extend T::Sig
  extend T::Generic
  abstract!

  ParamType = type_member(:in)

  sig {abstract.params(input: ParamType).returns(T::Boolean)}
  def do_task(input); end

  sig {params(input: T.all(ParamType, BasicObject)).returns(T::Boolean)}
  def do_task_with_logging(input)
    Kernel.puts(input)
    res = do_task(input)
    Kernel.puts(res)
    res
  end
end

class Task
  extend T::Sig
  extend T::Generic

  include ITask

  ParamType = type_member

  sig {params(fn: T.proc.params(param: ParamType).returns(T::Boolean)).void}
  def initialize(&fn)
    @fn = fn
  end

  sig {override.params(input: ParamType).returns(T::Boolean)}
  def do_task(input); @fn.call(input); end
end

sig {params(task: ITask[Integer]).void}
def example(task)
  i = 0
  while task.do_task_with_logging(i)
    i += 1
  end
end

takes_int_task = Task[Integer].new {|param| param < 10}

example(takes_int_task)

→ View full example on sorbet.run

Input and output positions

Understanding where covariant and contravariant type members can appear requires knowing which places in a method signature are output positions, and which are input positions.

An obvious output position is a method signature's returns annotation, but there are more than just that. As an intuition, all positions in a signature where the value is produced by some computation in the method's body are output positions. This includes values yielded to lambda functions and block arguments.

module IBox
  extend T::Sig
  extend T::Generic
  abstract!

  Elem = type_member(:out)

  sig {abstract.returns(Elem)}
  #                     ^^^^ output position
  def value; end

  sig do
    type_parameters(:U)
      .params(
        blk: T.proc.params(val: Elem).returns(T.type_parameter(:U))
        #                       ^^^^ output position
      )
      .returns(T.type_parameter(:U))
  end
  def with_value(&blk)
    yield value
  end
end

→ View on sorbet.run

In this example, both the result type of the value method and the val parameter that will be yielded to the blk parameter of with_value are output positions.

(The intuition for input positions is flipped: they're all positions that would correspond to an input to the function, instead of all things that the function produces. This includes the direct arguments of the method, as well as the return values of any lambda functions or blocks passed into the method.)

If it helps, some type systems actually formalize the type of a function as a generic something like this:

module Fn
  extend T::Sig
  extend T::Generic
  interface!

  Input = type_member(:in)
  Output = type_member(:out)

  sig {abstract.params(input: Input).returns(Output)}
  def call(input); end
end

sig do
  params(
    fn: Fn[Integer, String],
    x: Integer,
  )
  .returns(String)
end
def example(fn, x)
  res = fn.call(x)
  res
end

In the above example, Fn[Integer, String] is the type of a function that in Sorbet syntax would look like this:

T.proc.params(arg0: Integer).returns(String)

In fact, Sorbet uses exactly this trick. The T.proc syntax that Sorbet uses to model procs and lambdas is just syntactic sugar for something that looks like the Fn type above (there are some gotchas around functions that take zero parameters or more than one parameter, but the concept is the same).

Another intuition which may help knowing which positions are input and output positions: treat function return types as 1 and function parameters as -1. As you pick apart a function type, multiply these numbers together. A positive result means the result is an output position, while a negative means it's an input.

-1   +1
 A -> B

┌── -1 ──┐    ┌── +1 ──┐
 -1   +1       -1   +1
( C -> D ) -> ( E -> F )

In the first example, a simple function from type A to type B, B is the result of the function, so it's clearly in an output position. Similarly, A is in an input position.

The second example is the type of a function that takes a function as a parameter, having type C -> D, and produces another function as its output, having type E -> F. In this example, C is in the input position of an input position, making type C actually be in output position overall (-1 × -1 = +1). D is in the output position of an input position, and E is in the input position of an output position, so they're both in input positions (-1 × +1 = -1). F is in the output position of an output position, so it's also in output position (+1 × +1 = +1).

Variance positions and private

A special case is provided for private methods and instance variables: Sorbet does not check generic types for their variance position in private methods and instance variables.

If you need to allow, for example, a covariant generic type to appear in the input of a method, that method must be private. Note that Ruby treats the initialize method as private even if it is not defined as private explicitly, which is what allows accepting arguments typed with covariant type members in a constructor.

We can't really explain why this special case is carved out except by answering "why does tracking variance matter?"

Why does tracking variance matter?

To get a sense for why Sorbet places constraints on where covariant and contravariant type members can appear within signatures, consider this example, which continues the example from the covariance section above:

int_box = Box[Integer].new(value: 0)

# not allowed (attempts to widen type,
# but `Box::Elem` is invariant)
int_or_str_box = T.let(int_box, Box[T.any(Integer, String)])

# no error reported here
int_or_str_box.value = ''

T.reveal_type(int_box.value)
# Sorbet reveals: `Integer`
# Actual type at runtime: `String`

→ View full example on sorbet.run

The example starts with a Box[Integer]. Obviously, Sorbet should only allow this Box to store Integer values, and when reading values out of this box we should also be guaranteed to get an Integer.

If Sorbet allowed widening the type with the T.let in the example, then int_or_str_box would have type Box[T.any(Integer, String)]. Sensibly, Sorbet allows using int_or_str_box to write the value '' into the value attribute on the Box.

But that's a contradiction! int_box and int_or_str_box are the same value at runtime. The variables have different names and different types, but they're the same object in memory at runtime. On the last line when we read int_box.value, instead of reading 0, we'll read a value of '', which is bad—Sorbet statically declares that int_box.value has type Integer, which is out of sync with the runtime reality.

This is what variance checks buy in a type system: they prevent abstractions from being misused in ways that would otherwise compromise the integrity of the type checker's predictions.

As for private methods and instance variables (which don't have to respect variance positions), the short answer is that the general pattern above for how to produce a contradiction doesn't apply, and it's not possible to construct any other examples which would cause problems. The example above relied on being able to explicitly widen the type of the receiver of a method. Since it's not possible to widen the type of self, that class of bug doesn't apply.

(As a technicality, this is not quite true because of T.bind. Sorbet ignores this technicality because T.bind is itself already an escape hatch to get out of the type system's checks, like T.cast.)

A type_template example

So far, the discussion in this guide has focused on type_member's, which tend to be most useful for building things like generic containers.

The use cases for type_template's tend to look different: they tend to be used when a class wants to have something like an "abstract" type that is filled in by child classes. Here's an example of an abstract RPC (remote procedure call) interface, which uses type_template:

module AbstractRPCMethod
  extend T::Sig
  extend T::Generic

  abstract!

  # Note how these use `type_member` in this interface module
  # They become `type_template` because we `extend` this module
  # in the child class
  RPCInput = type_member
  RPCOutput = type_member

  sig {abstract.params(input: RPCInput).returns(RPCOutput)}
  def run(input); end
end

class TextDocumentHoverMethod
  extend T::Sig
  extend T::Generic

  # Use `extend` to start implementing the interface
  extend AbstractRPCMethod

  # The `type_member` become `type_template` because of the `extend`
  # We're using `fixed` to "fill in" the type_template. Read more below.
  RPCInput = type_template {{fixed: TextDocumentPositionParams}}
  RPCOutput = type_template {{fixed: HoverResponse}}

  sig {override.params(input: RPCInput).returns(RPCOutput)}
  def self.run(input)
    puts "Computing hover request at #{input.position}"
    # ...
  end
end

→ View full example on sorbet.run

The snippet above is heavily abbreviated to demonstrate some new concepts (type_template and fixed). The full example on sorbet.run contains many more details, and it's strongly recommended reading.

There are a couple interesting things happening in the example above:

  • We have a generic interface AbstractRPCMethod which says that it's generic in RPCInput and RPCOutput. It then mentions these types in the abstract run method. The example uses type_member to declare these generic types.

  • The interface is implemented by a class that uses extend to implement the interface using the singleton class of TextDocumentHoverMethod. As we know from Abstract Classes and Interfaces, that means TextDocumentHoverMethod must implement def self.run, not def run.

    In the same way, the type_member variables declared by the parent must be redeclared by the implementing class, where they then become type_template. Recall from the type_member & type_template section that the scope of a type_template is all singleton class methods on the given class.

  • In the implementation, TextDocumentHoverMethod chooses to provided a fixed annotation on the type_template definitions. This effectively says that TextDocumentHoverMethod always conforms to the type AbstractRPCMethod[TextDocumentPositionParams, HoverResponse]. We'll discuss fixed further below.

    Then when implementing the def self.run method, it can assume that RPCInput is equivalent to TextDocumentPositionParams. This allows it to access input.position in the implementation, a method that only exists on TextDocumentPositionParams (but not necessarily on every input to an AbstractRPCMethod).

Again, for more information, be sure to view the full example.

Bounds on type_member's and type_template's (fixed, upper, lower)

The fixed annotation in the example above places bounds on a type_template. There are three annotations for providing bounds to a type_member or type_template:

  • upper: Places an upper bound on types that can be applied to a given type member. Only subtypes of that upper bound are valid.

  • lower: The opposite—places a lower bound, thus requiring only supertypes of that bound.

  • fixed: Syntactic sugar for specifying both lower and upper at the same time. Effectively requires that an equivalent type be applied to the type member. Sorbet then uses this fact to never require that an explicit type argument be provided to the class.

class NumericBox
  extend T::Generic
  Elem = type_member {{upper: Numeric}}
end

class IntBox < NumericBox
  Elem = type_member {{fixed: Integer}}
end

NumericBox[Integer].new # OK, Integer <: Numeric
NumericBox[String].new
#          ^^^^^^ error: `String` is not a subtype of upper bound of `Elem`

IntBox.new
# ^ Does not need to be invoked like `IntBox[Integer]` because Sorbet can
#   trivially infer the type argument

Placing the bound on the type member makes it an error to ever instantiate a class with that member outside the given bound.

Generic methods

Methods can also be made generic in Sorbet:

# typed: true
extend T::Sig

sig do
  type_parameters(:U)
    .params(
      blk: T.proc.returns(T.type_parameter(:U))
    )
    .returns(T.type_parameter(:U))
end
def with_timer(&blk)
  start = Time.now
  res = yield
  duration = Time.now - start
  puts "Running block took #{duration.round(1)}s"
  res
end

res = with_timer do
  sleep 2
  puts 'hello, world!'
  123
end
T.reveal_type(res) # `Integer`

→ View on sorbet.run

The type_parameters method at the top-level of the sig block introduces generic type variables that can be referenced elsewhere in the signature using T.type_parameter. Names are specified as Ruby Symbol literals. Multiple symbol literals can be given to type_parameters, like this:

sig do
  type_parameters(:K, :V)
    .params(hash: T::Hash[T.type_parameter(:K), T.type_parameter(:V)])
    .returns([T::Array[T.type_parameter(:K)], T::Array[T.type_parameter(:V)]])
end
def keys_and_values(hash)
  [hash.keys, hash.values]
end

→ View on sorbet.run

Note Sorbet does not support return type deduction. This means that doing something like this won't work:

sig do
  type_parameters(:U)
    .returns(T.type_parameter(:U))
end
def returns_something
  nil
end

x = returns_something
puts(x) # error: This code is unreachable

→ View on sorbet.run

In the above example, the puts(x) is listed as "unreachable" for a somewhat confusing reason:

  • Sorbet sees that the T.type_parameter(:U) in the returns annotation is not constrained by of the arguments. It could therefore be anything.
  • The only type that is a subtype of any type in Sorbet is T.noreturn. The only way to introduce a value of this type is to raise an exception.
  • Therefore, Sorbet infers that the only valid way to implement returns_something is by raising, which would imply that the puts(x) code is never reached.

Placing bounds on generic methods

Sorbet does not have a way to place a bound on a generic method, but it's usually possible to approximate it with intersection types (T.all):

class A
  extend T::Sig
  sig {returns(Integer)}
  def foo; 0; end
end

sig do
  type_parameters(:U)
    .params(x: T.type_parameter(:U))
    .void
end
def bad_example(x)
  x.foo # error!
end

sig do
  type_parameters(:U)
    .params(x: T.all(T.type_parameter(:U), A))
    .returns(T.type_parameter(:U))
end
def example(x)
  x.foo
  if x.foo.even? # calls to `.foo` and `.even?` are OK
    return x # this return is OK
  else
    return A.new # this return is not OK
  end
end

→ View on sorbet.run

There are a couple of things worth pointing out here:

  • The bad_example method attempts to call x.foo but fails with an error. The error mentions that there is a call to method foo on an unconstrained generic type parameter. T.type_parameter(:U) alone means "for all types", but not all types have a foo method.

  • In the example method, the method's signature changes to ascribe the type T.all(T.type_parameter(:U), A) to x. Think of this as placing an upper bound of A on the generic T.type_parameter(:U). The example method can be called with more narrow types (e.g. if there were any subclasses of A), but not wider types, like Object, so the intersection type acts like an upper bound.

  • In the method body, the T.all is sufficient to allow the call to x.foo.even? to type check (and to have the type of T::Boolean statically).

  • The first return in the method works without error: x has type T.all(T.type_parameter(:U), A) which is a subtype of T.type_parameter(:U), so the return x type checks.

  • The second return in the method fails to type check: A.new has type A but it does not have type T.type_parameter(:U). This is not a bug. To see why, consider how Sorbet will typecheck a call site to example:

class ChildA < A; end
child = example(ChildA.new)
T.reveal_type(child) # => `ChildA`

In the snippet above, Sorbet knows that the method returns T.type_parameter(:U), which is the same as whatever the type of x is, which in this case is ChildA.

If Sorbet had allowed return A.new in the method body above, there would have been a contradiction: Sorbet would have claimed that child had type ChildA, but in fact it would have had type A, which is not a subtype of ChildA.

tl;dr: The only valid way to return something of type T.type_parameter(:U) is to return one of the method's arguments (or some piece of an argument), not by inventing an entirely new value.

Shortcomings of generic methods

Most commonly, when there is something wrong with Sorbet's support for generic methods, the error message mentions something about T.anything, or something about unreachable code. Whenever you see T.anything in an error message relating to a generic method, one of two things is happening:

  • There is a valid error, because the method's input type was not properly constrained. Double check the previous section on placing bounds on generic methods.

  • There is a bug or missing feature in Sorbet. Double check the list of issues in Sorbet's support for generics:

    → Issues with generics in Sorbet

    If nothing in the list looks relevant to the particular behavior at hand, please report a new issue. Note that we have limited resources, and may not be able to prioritize fixing such issues.

When encountering an error like this, there are a couple of choices:

  • Continue using generics, but use T.unsafe to silence the errors.

    Note that this can be quite burdensome: new programmers programming against the given API will be confused as to whether errors are their fault or Sorbet's.

  • Refactor the API to use T.untyped. This has the benefit of having Sorbet stay out of people's way, letting them write the code they'd like to be able to write. It obviously comes at the cost of Sorbet not being able to provide strong guarantees about correctness.

  • Find another way to type the API, potentially avoiding generics entirely. This might entail restructuring an API in a different way, using some sort of code generation, or something that merely doesn't trip the given bug. If you're stuck, ask for help.