Sorbet does not infer generic types when constructing an instance of a generic class.
set = Set.new([1])
T.reveal_type(set) # => T::Set[T.untyped]This is a bit of a longstanding bug. See #3768 and #4450. To learn more about why this happens, see this Sorbet internals doc.
You’d hope 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:
sig do
type_parameters(:Elem)
.params(val: T.type_parameter(:Elem))
.returns(T.all(T.attached_class, Box[T.type_parameter(:Elem)]))
end
def self.new(val)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
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.
To fix, we can define our own constructor:
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
class Box
# ...
sig do
type_parameters(:Elem)
.params(val: T.type_parameter(:Elem))
.returns(T.all(T.attached_class, Box[T.type_parameter(:Elem)]))
end
def self.new(val)
super
end
end
class ChildBox < Box
# ...
end
box = Box.new(0)
T.reveal_type(box) # => Box[Integer]
box = ChildBox.new(0)
T.reveal_type(box) # => ChildBox[Integer]Now, calling new on a Box produces
Box[Integer], while new on a
ChildBox produces ChildBox[Integer].
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.
How this works is that T.attached_class acts a little
bit like
T.attached_class[_]:
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.