Skip to content

Latest commit

 

History

History
203 lines (153 loc) · 8.12 KB

class-of.md

File metadata and controls

203 lines (153 loc) · 8.12 KB
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))
→ View on sorbet.run

T.class_of and inheritance

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
→ View on sorbet.run

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.

T.class_of and modules

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.run

Suppose 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:

  1. 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 to MyAbstractClass, all our problems vanish.

  2. 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.