Typing klass.new in Ruby with Sorbet

Update: While writing this post, I had a series of realizations and ended up building two features which make some of the parts of this post obsolete: has_attached_class! and T::Class.

I’ve rewritten some of the post below in light of those new features, but the core principles in this post are still useful, both to gain familiarity with Sorbet’s generic types and how to think about interface design in Sorbet.

With that out of the way…

A pattern like this comes up a lot in Ruby code:

def instantiate_class(klass)
  instance = klass.new
  # ... use `instance` somehow ...
  instance
end

(If you don’t believe me, try grepping your codebase for klass.new—you might be surprised. Where I work, I see well over 100 matches just using the variable name klass alone.)

The straightforward attempt at writing a Sorbet signature for this method doesn’t work. The strategy that does work uses abstract methods, which brings me to one of my most-offered tips for type-level design in Sorbet:

🏆   You should be using more abstract methods.

In this post, we’ll take a quick lookIf you’re short on time or don’t care for explanations, here’s the final code we’ll build towards.

at the most common incorrect approach to annotate this method, discuss why that approach doesn’t work, then circle back and see how to use abstract methods to type this method.

⛔️ What people try: T.class_of

This is the method signature people try to write:

sig do
  type_parameters()
    # !! This code does not work !!
    .params(T.class_of(T.type_parameter()))
    .returns(T.type_parameter())
end
def instantiate_class(klass)
  instance = klass.new
  # ...
  instance
end

This type does not work.Sometimes I wish Sorbet had used the syntax A.singleton_class instead of T.class_of(A), because I think it might have made it more clear that you can’t do this on arbitrary types. Then again, maybe people would have just done T.any(A, B).singleton_class

Even though I can see why people might expect it to work, there are reasons why it should not work, and the Sorbet docs elaborate why.

In short, T.type_parameter(:U) doesn’t stand for “some unknown class,” it stands for “some unknown type.” It could mean any of T.any(Integer, String), T::Array[Integer], T.noreturn, or any other type. Meanwhile, T.class_of(...) is defined very narrowly to mean “get the singleton class of ....” Arbitrary types don’t have singleton class, only classes have singleton classes.

⚠️️ How to mostly solve this with T::Class

As of May 2023, Sorbet has a separate feature, called T::Class[...], which does work the way people have expected T.class_of to work:

sig do
  type_parameters()
    .params(T::Class[T.type_parameter()])
    .returns(T.type_parameter())
end
def instantiate_class(klass)
  instance = klass.new
  # ...
  instance
end

This code works, but it comes with the downside that the call to new is not statically checked. Here we passed no arguments, but it might be that klass’s constructor has one or more required arguments.

✅ How to solve this with abstract methods

When we see something like this:

def instantiate_class(klass)
  klass.new
end

and we want to write a precise signature here, what’s critical is to notice that there is some de-facto API that klass is meant to conform to. That’s exactly what interfaces are for.

In particular, the de-facto API is that klass has some method that tells us how to create instances, and that method takes no arguments. Let’s translate that API to an interface:

module ThingFactory
  extend T::Generic
  interface!

  has_attached_class!()

  sig {abstract.returns(T.attached_class)}
  def make_thing; end
end

sig do
  type_parameters()
    .params(ThingFactory[T.type_parameter()])
    .returns(T.type_parameter())
end
def instantiate_class(klass)
  klass.make_thing
end
→ View on sorbet.run

This ThingFactory has two notable definitions: a method called make_thing, and a call to has_attached_class! above that. has_attached_class! both allows using T.attached_class in instance methods of this module and makes this module generic in that attached class. It’s a way for Sorbet to track the relationship between one type and the type of instances it constructs.

Naming the method make_thing (instead of new) is a slight sacrifice. Choosing a name other than new helps Sorbet check that all classes accept the same number of constructor arguments, with compatible types. (Technically, we could use a method named new in our interface, but that runs into a handful of fixable or maybe unfixable bugs. It’s kind of up to you whether you care about the convenience of using the name new everywhere at the cost of these bugs.)

Personally, I like that choosing a different name makes implementing the interface more explicit, and thus easier for future readers to see what’s going on.

In any case, here’s how we can implement that interface:

class GoodThing
  extend ThingFactory

  sig {override.returns(T.attached_class)}
  def self.make_thing # ✅
    new
  end
end

class BadThing
  extend ThingFactory

  sig {override.params(Integer).returns(T.attached_class)}
  def self.make_thing(x) # ⛔️ must accept no more than 0 required arguments
    new
  end
end
→ View complete example on sorbet.run

See how the BadThing class attempts to incompatibly implement make_thing? Sorbet correctly reports an error on line 14 saying that make_thing must not accept an extra required argument.

Something else worth mentioning: we’re implementing this interface on the singleton class of GoodThing and BadThing:

So far so good: we’ve successfully annotated our instantiate_class method! But we can actually take it one step further.

🔧 Extending the abstraction

Sometimes, the snippet we’re trying to annotate isn’t just doing klass.new. Rather, it’s instantiating an object and then calling some method on that instance. The type we’ve written so far won’t allow that:

# sig {...}
def instantiate_class
  instance = klass.make_thing
  instance.foo
  #        ^^^ ⛔️ Call to method `foo` on unconstrained generic type
  instance
end

This is yet another another problem we can solve with abstract methods.

First, we define some interface AbstractThing which has an abstract foo method on it. (Depending on the code we’re trying to annotate, such an interface might already exist!)

module AbstractThing
  extend T::Helpers
  abstract!

  sig {abstract.returns(Integer)}
  def foo; end
end

# ...

class GoodThing
  extend T::Generic
  include AbstractThing
  extend ThingFactory

  # ...

  sig {override.returns(Integer)}
  def foo; 0; end
end

With that interface in hand, we use T.all to constrain the generic type argument to ThingFactory.

sig do
  type_parameters()
    .params(
      ThingFactory[
        T.all(AbstractThing, T.type_parameter())
      ]
    )
    .returns(T.type_parameter())
end
def instantiate_class(klass)
  instance = klass.make_thing
  instance.foo # ✅ OK
  instance
end
→ View complete example on sorbet.run

This has the effect of ensuring that the foo method we want to call is available on all instances, no matter which kind of instance it is. (For more reading, this use of T.all is how Sorbet approximates bounds on T.type_parameters.)

If we find ourselves repeatedly calling self.make_thing.foo, we might want to pull that code into the ThingFactory interface. That’s totally fine, but it’ll mean that we’ll use upper: on the Instance type member to apply the bound, instead of T.all:

module ThingFactory
  extend T::Generic
  abstract!

  has_attached_class!() { {AbstractThing} }

  sig {abstract.returns(T.attached_class)}
  def make_thing; end

  sig {returns(T.attached_class)}
  def make_thing_and_call_foo
    instance = self.make_thing
    instance.foo # ✅ also OK
    instance
  end
end
→ View complete example on sorbet.run

The takeaway is that if we want to call specific methods after instantiating some arbitrary class, we need an interface and a bound. Where to put the bound (on the method or on the type member) is up to personal preference. Some tradeoffs:

🧹 Cleaning up the code

Altogether, this code works, and I’ve presented it in such a way as to illustrate the concepts as plainly as possible. But it’s maybe not the most idiomatic Sorbet code imaginable.

We have two interfaces (AbstractThing and ThingFactory) that are conceptually related, but not related in the code. Realistically, everything that implements one needs to implement both. We can make that connection explicit with mixes_in_class_methods.

module Thing
  extend T::Helpers
  interface!

  sig {abstract.returns(Integer)}
  def foo; end

  module Factory
    extend T::Generic
    interface!

    has_attached_class!() { {Thing} }

    sig {abstract.returns(T.attached_class)}
    def make_thing; end
  end
  mixes_in_class_methods(Factory)
end

# ...

class GoodThing
  extend T::Generic
  include Thing

  # ...
end
→ View complete example on sorbet.run

By using mixes_in_class_methods, we replace an include + extend with just a single include. Also, it gives us an excuse to nest one module inside the other, so that we can have Thing and Thing::Factory, names which read more nicely in my opinion. (Of course, you’re free to use whatever names you like.)



That should be all you need to go forth and add types to code doing klass.new. One more time, here’s the complete final example:

→ View complete final example on sorbet.run

That being said, the concepts presented in this post are quite advanced and also uncommonly discussed online. If reading this post left you feeling unclear or confused about something, please reach out. I’d love to update the post with your feedback.



Trivia

This section is just “other neat things.” You should be able to safely skip it unless you want to learn more about some esoteric parts of the implementation of Sorbet which are related to concepts discussed above.

T.attached_class

Internally, T.attached_class is secretly a type_member, declared something like this, automatically, in every class:

class MyClass
  <AttachedClass> = type_template() { {MyClass} }
end

Those angle brackets in the name are not valid Ruby syntax, which ensures that people can’t write a type member with this name, and e.g. overwrite the meaning of T.attached_class.

That’s where has_attached_class! comes in! It essentially provides syntactic sugar to let people define these <AttachedClass> generic types, without having to conflict with any names of constants the user might already be using in that class.

Realizing that we could build has_attached_class! with just a syntactic rewrite was a key insight that unblocked most of the work on T::Class that had blocked us from making progress on this feature in the past. There’s more context in the original pull request.

Two modules vs one class

In the mixes_in_class_methods example above, it’s reasonable to try to unify Thing and Thing::Factory into a single AbstractThing class:

# !! warning, doesn't work !!
class AbstractThing
  extend T::Generic
  abstract!

  sig {abstract.returns(Integer)}
  def foo; end
end

From a type system perspective, this is actually totally fine. But the one problem is that there’s no replacement for what we used to be able to write with Factory[...]. Said another way: there’s no way to apply a type argument to a generic singleton class (i.e., to a type template). This is purely a question of syntax. Specifically:

sig do
  type_parameters()
    .params(T.class_of(AbstractThing)[T.type_parameter()])
    .returns(T.type_parameter())
end
def instantiate_class(klass)
  # ...
end

This T.class_of(AbstractThing)[...] syntax isn’t parsed by Sorbet. If we can bikeshed on a syntax, the type system would very easily admit such a feature (because type_template is literally just a type_member on the singleton class).

But sometimes, bikeshedding syntax is the hardest part of language design.