T.attached_class
T.attached_class can be used to refer to the type of instances from a singleton-context, in a way that is friendly to inheritance.
class LoadableData
extend T::Sig
sig { params(id: Integer).returns(T.attached_class) }
def self.load_by_id(id)
new
end
end
class Article < LoadableData; end
class User < LoadableData; end
class Administrator < User; end
T.reveal_type(Article.load_by_id(10)) # Article
T.reveal_type(User.load_by_id(15)) # User
T.reveal_type(Administrator.load_by_id(20)) # Administrator
Typing factory methods
Consider the following example of a class hierarchy with a factory method:
class Parent
extend T::Sig
# Correct, but not ideal return type
sig { returns(Parent) }
def self.make
new
end
end
class Child < Parent
sig { void }
def say_hi
puts "hi"
end
end
T.reveal_type(Parent.make) # Parent
T.reveal_type(Child.make) # Parent
Child.make.say_hi # Error: say_hi doesn't exist on Parent
Parent defines a single factory method self.make, which will create an instance of the Parent class. When this method is called off of the Parent class, it returns a new instance of type Parent. When it is called on Child it creates an instance of Child but the signature of Parent.make will cause the return value to be up-cast to Parent. This is fine if it’s convenient to use constructed Child values as though they were Parent, but becomes problematic if we need to call methods that are unique to Child instances, like in the case of Child.make.say_hi.
This is where T.attached_class comes in: changing the return type of Parent.make to T.attached_class indicates that it returns instances of the class the singleton method was called on. When called from Parent, it will return values of type Parent, and when called from Child it will return values of type Child. This resolves the type errors in the previous example with Child.make.say_hi,
class Parent
extend T::Sig
sig { returns(T.attached_class) }
def self.make
new
end
end
class Child < Parent
sig { void }
def say_hi
puts "hi"
end
end
T.reveal_type(Parent.make) # Parent
T.reveal_type(Child.make) # Child
Child.make.say_hi # No error now, as Child.make has the type Child
T.attached_class as an argument?
Using T.attached_class as the type of parameters is an error in sorbet, and allowing it would cause soundness issues. To see why, let’s take a look at an example:
class Parent
extend T::Sig
sig { returns(T.attached_class) }
def self.make
new
end
sig { params(x: T.attached_class).void } # A bad type definition for `x`
def self.consume(x)
puts "consumed"
end
end
class Child < Parent
extend T::Sig
sig { void }
def say_hi
puts "hi"
end
sig { params(x: T.attached_class).void } # A bad type definition for `x`
def self.consume(x)
x.say_hi
end
end
Parent.consume(Parent.new)
Child.consume(Parent.new) # We would like this to be an error
Problems arise when you begin passing around singleton classes. Imagine that you write the method below, that accepts arguments of type T.class_of(Parent).
class A
extend T::Sig
sig { params(cls: T.class_of(Parent)).void }
def self.consume_parent(cls)
cls.consume(Parent.make)
end
end
For the call to cls.consume in the body of A.consume_parent, we are always passing an argument whose type is Parent. Let’s walk through two different calls to A.consume_parent:
A.consume_parent(Parent)Parenthas typeT.class_of(Parent), and is acceptable forclsParent.consumeaccepts values of typeParent(viaT.attached_class)
A.consume_parent(Child)Childhas typeT.class_of(Child), which is a subtype ofT.class_of(Parent)and is acceptable forclsChild.consumeaccepts arguments of typeChild(viaT.attached_class), and thus will raise an error whensay_hiis called on the instance produced byParent.make
As these two cases show, Sorbet can’t know whether the body of A.consume_parent type checks or not without knowledge about what type cls is at runtime. Because of this problem, T.attached_class is only allowed to show up in the returns part of a signature, and if it does show up in the params of a signature, you’ll get an error:
class Parent
extend T::Sig
sig { params(x: T.attached_class).void }
# ^: T.attached_class may only be used in an :out context, like returns
def self.problem(x); end
end
As a final note: none of these problems would happen if consume were private:
- The bad call to
Child.consume(Parent.new)would not be allowed, becauseconsumewould be private. - The bad call to
A.consume_parent(Child)would not be allowed because the body ofconsume_parentcontainscls.consume, which is a non-private call to a private method.
As such, Sorbet allows T.attached_class to appear in input (:in) positions of private methods.
T.attached_class common problems
One common problem people encounter when using T.attached_class looks something like this:
# typed: true
class Parent
extend T::Sig
sig { returns(T.attached_class) }
def self.make
Parent.new # (1) Sorbet reports an error here
end
end
class Child < Parent; end
T.reveal_type(Child.make) # Revealed type: `Child`
In this example, the program attempts to return Parent.new from self.make, instead of just self.new. There is a key difference: Parent.new will always make an instance of Parent, while self.new will make an instance of the particular subclass that self.make was called on.
For this reason, Sorbet has to report an error at comment (1) above. If we consider the Child class that inherits from Parent, the T.attached_class type suggests to the caller that Child.make will return a Child instance (the T.reveal_type confirms this).
Since Parent.new is not an instance of Child or any other potential subclasses of Parent, Sorbet must reject the code at (1).
has_attached_class!: T.attached_class in module instance methods
Some modules are only ever eventually mixed into a class with extend (not include), meaning that any methods that module defines will eventually be called like singleton class methods.
These modules usually want to be able to call new to instantiate an instance of the class that the module is extend'd into. That’s a problem because normally constructor methods like that would have a return type of T.attached_class, but instance methods cannot mention T.attached_class.
To allow instance methods in such modules to use T.attached_class, Sorbet provides the has_attached_class!(:out) annotation:
module FinderMethods
extend T::Sig
extend T::Generic
abstract!
has_attached_class!(:out)
sig { abstract.returns(T.attached_class) }
def new; end
sig { params(id: String).returns(T.attached_class) }
def find(id)
self.new
end
end
class ParentModel
extend T::Sig
extend FinderMethods
end
class ChildModel < ParentModel
end
parent = ParentModel.find('pa_123')
T.reveal_type(parent) # => `ParentModel`
child = ChildModel.find('ch_123')
T.reveal_type(child) # => `ChildModel`
Some things to note:
The
has_attached_class!method is exposed as a method inT::Generic, because usinghas_attached_class!implicitly makes the module into a generic module. This is why modules are not allowed to useT.attached_classby default. It’s also why the:outis there: as a reminder thatT.attached_classmust obey the same rules astype_member(:out). More on this in a moment.We’ve declared
newas an abstract method. This method is automatically detected to be implemented whenextend'd into a class (because all classes inherit a concreteself.newmethod).This abstract
newmethod allows callingnewin thefindmethod. If thisfindmethod had been in an RBI file (not in a source file), then the abstractnewdeclaration would not have been required, because there would be no method bodies to type check.The
FinderMethodsmodule isextendedintoParentModel. Had this beeninclude FinderMethods, Sorbet would have reported an error saying thatFinderMethodscan only be extended into classes.The two calls to
findat the bottom of the snippet reveal thatfind's type changes based on the type of the method call’s receiver. Basically:findon aChildModelwill be aChildModel.
Generics and has_attached_class!
We mentioned above that using has_attached_class! in a module makes the module into a generic module. The mental model is to think of has_attached_class! as syntactic sugar for putting a type_member with an unmentionable name into the module. This type_member, having no explicit name, can then be referenced using T.attached_class.
What this means is that it’s possible to abstract over the attached class of an has_attached_class! module, the same as any other generic interface:
sig do
type_parameters(:U)
.params(
findable: FinderMethods[T.type_parameter(:U)]
id: String
)
.returns(T.type_parameter(:U))
end
def find_and_log(findable, id)
instance = findable.find(id)
puts("Found #{instance}")
instance
end
parent = find_and_log(ParentModel, 'pa_123')
T.reveal_type(parent) # => `ParentModel`
child = find_and_log(ChildModel, 'pa_123')
T.reveal_type(child) # => `ChildModel`
Note how we’ve annotated the findable parameter as FinderMethods[...], indicating that we’re using the FinderMethods module generically. In fact, supplying a type argument to the FinderMethods is now required: it’s not possible to reference FinderMethods in a type position without providing a type annotation. If you truly must ignore this, supply a type argument like BasicObject or T.untyped (or accept the autocorrect on the error, which will insert T.untyped by default).
As a generic, has_attached_class! takes the same arguments that type_member takes for things like variance and bounds:
# Declares a covariant type member:
has_attached_class!(:out)
# Places a bound on the type member:
has_attached_class! { {upper: SomeInterface} }
# Altogether:
has_attached_class!(:out) { {upper: SomeInterface} }
(Note that this type member cannot be declared contravariant, as that would make it impossible to mix this module into a class.)
Note: you may also find this external blog post useful, which discusses similar topics: