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_type
and/orT.assert_type!
to inspect the types of intermediate values to see ifT.untyped
has silently snuck in somewhere. IfT.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:
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
fixed
andtype_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:
- 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
Factory
class can call that method inmake
to 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
fixed
annotation. - The subclass implements
instance_type
withA
. Sorbet checks that the implementationinstance_type
and the value provided toInstanceType
remain 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_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 inX
", - "
Example
is covariant inY
", and - "
Example
is 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.
:out
)
Covariance (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.
:in
)
Contravariance (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
).
private
Variance positions and 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
.)
type_template
example
A 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 inRPCInput
andRPCOutput
. It then mentions these types in the abstractrun
method. The example usestype_member
to declare these generic types.The interface is implemented by a class that uses
extend
to implement the interface using the singleton class ofTextDocumentHoverMethod
. As we know from Abstract Classes and Interfaces, that meansTextDocumentHoverMethod
must implementdef self.run
, notdef run
.In the same way, the
type_member
variables declared by the parent must be redeclared by the implementing class, where they then becometype_template
. Recall from thetype_member
&type_template
section that the scope of atype_template
is all singleton class methods on the given class.In the implementation,
TextDocumentHoverMethod
chooses to provided afixed
annotation on thetype_template
definitions. This effectively says thatTextDocumentHoverMethod
always conforms to the typeAbstractRPCMethod[TextDocumentPositionParams, HoverResponse]
. We’ll discussfixed
further below.Then when implementing the
def self.run
method, it can assume thatRPCInput
is equivalent toTextDocumentPositionParams
. This allows it to accessinput.position
in 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.
type_member
's and type_template
's (fixed
, upper
, lower
)
Bounds on 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 bothlower
andupper
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`
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 thereturns
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 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_example
method attempts to callx.foo
but fails with an error. The error mentions that there is a call to methodfoo
on an unconstrained generic type parameter.T.type_parameter(:U)
alone means "for all types", but not all types have afoo
method.In the
example
method, the method’s signature changes to ascribe the typeT.all(T.type_parameter(:U), A)
tox
. Think of this as placing an upper bound ofA
on the genericT.type_parameter(:U)
. Theexample
method 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.all
is sufficient to allow the call tox.foo.even?
to type check (and to have the type ofT::Boolean
statically).The first
return
in the method works without error:x
has typeT.all(T.type_parameter(:U), A)
which is a subtype ofT.type_parameter(:U)
, so thereturn x
type checks.The second return in the method fails to type check:
A.new
has typeA
but 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.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.