A trick for generic constructors in Sorbet

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]

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:

sig do
  type_parameters()
    .params(T.type_parameter())
    .returns(T.all(T.attached_class, Box[T.type_parameter()]))
end
def self.new(val)
View final example on sorbet.run →

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(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()
      .params(T.type_parameter())
      .returns(Box[T.type_parameter()])
  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:

class ChildBox < Box
  # ...
end

box = ChildBox.new(0)
T.reveal_type(box) # => Box[Integer]

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()
      .params(T.type_parameter())
      .returns(T.all(T.attached_class, Box[T.type_parameter()]))
  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]
View full example on sorbet.run →

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.