Sorbet does not infer generic types when constructing an instance of a generic class.
= Set.new([1])
set .reveal_type(set) # => T::Set[T.untyped] T
You’d hopeThis is a bit of a longstanding bug. See #3768 and #4450. To learn more about why this happens, see this Sorbet internals doc.
that Sorbet would either report an error, requiring a type annotation instead of implicitly assuming T.untyped
(like how other type annotations are required in # typed: strict
files), or be smart enough to infer a suitable type from the provided arguments. Some day it will.
But in the mean time, if you want to build your own generic classes that don’t suffer from this limitation, there’s another way forward: defining a custom constructor with a clever signature.
The end solution is going to look something like this:
But for that to make sense, let’s build up to it.
Recap of the problem
class Box
extend T::Generic
Elem = type_member
sig { params(val: Elem).void }
def initialize(val)
@val = val
end
end
box = Box.new(0)
T.reveal_type(box) # => Box[T.untyped]
box = Box[Integer].new(0)
T.reveal_type(box) # => Box[Integer]
If we naively define a generic Box
class like this, Box.new(0)
will neither report an error nor infer the correct Box[Integer]
type. Users of our class can correct this themselves with Box[Integer].new(0)
, but few people know to do this.
The problem here comes from Sorbet’s default implementation of Class#new
: by default, Sorbet uses only the type of the receiver to decide the result type. We can fix the problem by defining our own constructor as a generic method whose return type is inferred from the arguments.
Defining a custom constructor
To fix, we can define our own constructor:If you don’t like overriding Class#new
, the same tricks work with a custom constructor like def self.make
which calls self.new
. If you do this, you likely also want private_class_method :new
.
class Box
# ...
sig do
type_parameters(:Elem)
.params(val: T.type_parameter(:Elem))
.returns(Box[T.type_parameter(:Elem)])
end
def self.new(val)
super
end
end
box = Box.new(0)
T.reveal_type(box) # => Box[Integer]
Now the inferred type is Box[Integer]
even though we didn’t provide an explicit type annotation like Box[Integer].new(...)
.
This signature is pretty good, but we can actually do better. This signature says that the result will always be a Box
, even if called on subclasses of Box
:
Notice that the ChildBox.new
call produces a Box
, because our override of new
said “I always return Box[...]
.” We can fix that with clever use of T.all
and T.attached_class
.
Handling subclasses with T.all
and T.attached_class
Now, calling new
on a Box
produces Box[Integer]
, while new
on a ChildBox
produces ChildBox[Integer]
.
How this works is that T.attached_class
acts a little bit like T.attached_class[_]
:This [_]
syntax doesn’t exist, but hopefully it’s suggestive that in this case, T.attached_class
actually stands for some generic type, because the attached class of Box
is generic.
it represents whatever the current attached class is (either Box
or ChildBox
), but knows nothing about the applied type arguments. Meanwhile, the Box[T.type_parameter(:Elem)]
knows about the type arguments, but has an overly broad view of the class those arguments are applied to.
Combining everything with T.all
asks Sorbet to collapse all the parts pairwise: pick the most specific class to apply arguments to, and pick the most specific of all the supplied type arguments.