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.