Generic Classes and Methods
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:
- Use generics incorrectly, and not be told as much by Sorbet
- 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_typeand/orT.assert_type!to inspect the types of intermediate values to see ifT.untypedhas silently snuck in somewhere. IfT.untypedhas 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:
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
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`
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
fixedandtype_templatewhich 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:
- We declare an abstract method that uses
T::Class[InstanceType]for its return type. Subclasses are forced to fill this in (or remain abstract). - The parent
Factoryclass can call that method inmaketo access the class at runtime. UnlikeInstanceType.new, this actually produces the correct class at runtime. - 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
fixedannotation. - The subclass implements
instance_typewithA. Sorbet checks that the implementationinstance_typeand the value provided toInstanceTyperemain in sync because of theT::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_memberis 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_templateis all singleton class methods on the given class. Since a class only has one singleton class,type_templatevariables 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:
- "
Exampleis invariant inX", - "
Exampleis covariant inY", and - "
Exampleis contravariant inZ", 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
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
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
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
AbstractRPCMethodwhich says that it’s generic inRPCInputandRPCOutput. It then mentions these types in the abstractrunmethod. The example usestype_memberto declare these generic types.The interface is implemented by a class that uses
extendto implement the interface using the singleton class ofTextDocumentHoverMethod. As we know from Abstract Classes and Interfaces, that meansTextDocumentHoverMethodmust implementdef self.run, notdef run.In the same way, the
type_membervariables declared by the parent must be redeclared by the implementing class, where they then becometype_template. Recall from thetype_member&type_templatesection that the scope of atype_templateis all singleton class methods on the given class.In the implementation,
TextDocumentHoverMethodchooses to provided afixedannotation on thetype_templatedefinitions. This effectively says thatTextDocumentHoverMethodalways conforms to the typeAbstractRPCMethod[TextDocumentPositionParams, HoverResponse]. We’ll discussfixedfurther below.Then when implementing the
def self.runmethod, it can assume thatRPCInputis equivalent toTextDocumentPositionParams. This allows it to accessinput.positionin the implementation, a method that only exists onTextDocumentPositionParams(but not necessarily on every input to anAbstractRPCMethod).
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 bothlowerandupperat 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`
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
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
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 thereturnsannotation 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_somethingis by raising, which would imply that theputs(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
There are a couple of things worth pointing out here:
The
bad_examplemethod attempts to callx.foobut fails with an error. The error mentions that there is a call to methodfooon an unconstrained generic type parameter.T.type_parameter(:U)alone means "for all types", but not all types have afoomethod.In the
examplemethod, the method’s signature changes to ascribe the typeT.all(T.type_parameter(:U), A)tox. Think of this as placing an upper bound ofAon the genericT.type_parameter(:U). Theexamplemethod can be called with more narrow types (e.g. if there were any subclasses ofA), but not wider types, likeObject, so the intersection type acts like an upper bound.In the method body, the
T.allis sufficient to allow the call tox.foo.even?to type check (and to have the type ofT::Booleanstatically).The first
returnin the method works without error:xhas typeT.all(T.type_parameter(:U), A)which is a subtype ofT.type_parameter(:U), so thereturn xtype checks.The second return in the method fails to type check:
A.newhas typeAbut it does not have typeT.type_parameter(:U). This is not a bug. To see why, consider how Sorbet will typecheck a call site toexample:
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.unsafeto 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.