id | title | sidebar_label |
---|---|---|
class-of |
Types for Class Objects via T.class_of |
T.class_of |
Classes are also values in Ruby. Sorbet uses T.class_of(...)
to describe the
types of those class objects.
T.class_of(Integer)
The difference between MyClass
and T.class_of(MyClass)
can be confusing.
Here are some examples to make it less confusing:
These expressions... | ...have these types |
---|---|
0 , 1 , 2 + 2 |
Integer |
Integer |
T.class_of(Integer) |
42.class |
T.class_of(Integer) |
Here's a playground link to confirm these types:
# typed: true
T.let(0, Integer)
T.let(1, Integer)
T.let(2 + 2, Integer)
T.let(Integer, T.class_of(Integer))
T.let(42.class, T.class_of(Integer))
As with Class Types, T.class_of
types work with
inheritance:
# typed: true
extend T::Sig
class Grandparent; end
class Parent < Grandparent; end
class Child < Parent; end
sig {params(x: T.class_of(Parent)).void}
def example(x); end
example(Grandparent) # error
example(Parent) # ok
example(Child) # ok
The most surprising feature of T.class_of
comes from not understanding
inheritance in Ruby, especially with include
or extend
plus modules.
See below for a common gotcha.
TL;DR: T.class_of
has some unintuitive behavior with modules (as opposed
to classes). Consider either using an abstract class or using
T.all(Class, MyInterface::ClassMethods)
instead of T.class_of(MyInterface)
.
To showcase the problem and solutions, let’s walk through a running example. The full code for this example is available here:
→ View on sorbet.runSuppose we have some code like this:
class MyClass
def some_instance_method; end
def self.some_class_method; end
end
sig {params(x: T.class_of(MyClass)).void}
def example1(x)
x.new.some_instance_method # ok
x.some_class_method # ok
end
example1(MyClass) # ok
MyClass
declares a class which has an instance method and a class method. The
T.class_of(MyClass)
annotation allows example1
to call both those methods.
None of this is too surprising.
Now imagine that we have a lot of these classes and we want to factor out an
interface. The straightforward way to do this uses
mixes_in_class_methods
, like
this:
module MyInterface
extend T::Helpers
def some_instance_method; end
module ClassMethods
def some_class_method; end
end
mixes_in_class_methods(ClassMethods)
end
class MyClass
include MyInterface
end
This will make some_instance_method
and some_class_method
available on
MyClass
, just like before. But if we try to replace T.class_of(MyClass)
with
T.class_of(MyInterface)
, it doesn’t work:
sig {params(x: T.class_of(MyInterface)).void} # ← sig has changed
def example2(x)
x.new.some_instance_method # error: `new` does not exist
x.some_class_method # error: `some_class_method` does not exist
end
example2(MyClass) # error: Expected `T.class_of(MyInterface)`
# but found `T.class_of(MyClass)`
These errors are correct, and we can verify them in the Ruby REPL. First, let's explain the error on the last line above:
❯ MyClass.singleton_class.ancestors
=> [#<Class:MyClass>, MyInterface::ClassMethods, #<Class:Object>, T::Private::Methods::MethodHooks, #<Class:BasicObject>, Class, Module, T::Sig, Object, Kernel, BasicObject]
The first two ancestors of the MyClass
object are itself and
MyInterface::ClassMethods
. But notably, #<Class:MyInterface>
does not
appear in this list, so Sorbet is correct to say that MyClass
does not have
type T.class_of(MyInterface)
. This is because neither include
nor extend
in Ruby will cause #<Class:MyInterface>
to appear in any ancestors list.
Next, let's explain the other two errors:
❯ MyInterface.singleton_class.ancestors
=> [#<Class:MyInterface>, T::Private::MixesInClassMethods, T::Helpers, Module, T::Sig, Object, Kernel, BasicObject]
For the MyInterface
class object, we see that its only ancestor is itself
(ignoring common ancestors like Object
). Notably, none of the classes in
this list define either a method called new
(because Class
is not there) nor
some_class_method
(because MyInterface::ClassMethods
is not there).
While these errors are technically correct, we want to be able to type this code. There are two options:
-
Use an abstract class instead of an interface.
Sometimes this is not possible, because the class in question already has a superclass that can't be changed. However, if this option is available, it's likely the most straightforward. If we change
MyInterface
toMyAbstractClass
, all our problems vanish. -
Use
T.all(Class, MyInterface::ClassMethods)
.For our example this is only a partial solution, but in many cases it is good enough.
Specifically, option (2) looks like this:
sig {params(x: T.all(Class, MyInterface::ClassMethods)).void}
def example3(x)
x.new.some_instance_method # error: `some_instance_method` does not exist
x.some_class_method # ok
end
example3(MyClass) # ok
We’re down to only one error now. The error is still technically correct: since
we’re using Class
instead of T.class_of(...)
, Sorbet has no way to know what
the instance type created by x.new
will be (it could be anything), so it
treats the type as Object
, causing some_instance_method
to not be found.
However, both the top-level call site to example3
and the call to
x.some_class_method
now typecheck successfully. In cases where we don't
actually need to use instance methods from MyInterface
, this may be an
acceptable workaround.
A future feature of Sorbet might be able to improve this workaround. See sorbet#62.