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!
andT::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)
= klass.new
instance # ... use `instance` somehow ...
instanceend
(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(:U)
# !! This code does not work !!
.params(klass: T.class_of(T.type_parameter(:U)))
.returns(T.type_parameter(:U))
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:
do
sig :U)
type_parameters(.params(klass: T::Class[T.type_parameter(:U)])
.returns(T.type_parameter(:U))
end
def instantiate_class(klass)
= klass.new
instance # ...
instanceend
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)
.new
klassend
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:
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:
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
:
- On line 3, we use
extend
(instead ofinclude
) to mix in the interface. - On line 5,
def make_thing
from the interface becomesdef self.make_thing
. Also, since it’s now a singleton class method, we can useT.attached_class
for free (no need for an extra call tohas_attached_class!
or anything).
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
.
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_parameter
s.)
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
:
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:
Bounding the type member means you can only use this
ThingFactory
interface withAbstractThing
, preventing it from being used for anything else. Maybe that’s what you want, or maybe it isn’t.Bounding the type member might make for more obvious errors. For example, if someone accidentally wrote the wrong type in the
fixed
bound, a single error will show, right there. Had the bound been on the method, errors would appear at every call toinstantiate_class
(which is annoying because the proper fix will be to go back, find thefixed
, and correct the typo).
🧹 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
.
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(:out) { {upper: 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!
{abstract.returns(Integer)}
sig 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(:Instance)
.params(klass: T.class_of(AbstractThing)[T.type_parameter(:Instance)])
.returns(T.type_parameter(:Instance))
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.