Sometimes I get a Sorbet question like this, and it brings me nothing but shame:
Is there a way to specify that a method accepts a
T.class_of(Foo)
whereFoo
is an abstract class, but all callers to this function must pass non-abstract classes?
It’s one of Sorbet’s original sins rearing its head: choosing to allow abstract singleton class methods. The choice is an unsound compromise, made to allow easier adoption in existing Ruby codebases.
If you haven’t come across it before, the problem with abstract singleton class methods boils down to this:
class AbstractParent
abstract!
sig { abstract.void }
def self.foo; end
end
class ConcreteChild < AbstractParent
sig { override.void }
def self.foo = puts("hello!")
end
sig { params(klass: T.class_of(AbstractParent)).void }
def example(klass)
klass.foo
end
example(ConcreteChild) # ✅
example(AbstractParent) # 💥 call to abstract method foo
The call to foo
on line 14 expects klass
to
be an instance of a concrete class, so all its methods (including
foo
) have implementations. But at runtime, the object
AbstractParent
is not an instance of a
concrete class.Specifically: it’s the singleton instance of a
singleton class which Sorbet allowed defining abstract methods on.
The method foo
is not implemented on the
AbstractParent
object, so the call raises unexpectedly at
runtime despite Sorbet reporting no static error.
In every sane language, making a type abstract is
supposed to prevent this problem! That is, if
A
is abstract, then having x
with type
A
should necessarily imply that whatever x
is
bound to at runtime is an instance of a concrete subclass of
A
. Abstract classes should not be instantiable!
For non-singleton classes, Sorbet enforces this guarantee: marking a
class abstract!
hijacks the self.new
method at
runtime to make it raise an exception, which prevents instantiating
abstract classes.… ignoring Ruby trickery which well-behaved programs
won’t use.
But for singleton classes, there’s no way to prevent a class’s singleton class from being created—the act of declaring a class automatically creates the singleton class. Knowing this, Sorbet should never consider a singleton class to be abstract, preventing the declaration of abstract singleton class methods. It does anyway, which is how we ended up with this mess.
Strive to avoid designs that depend on abstract singleton
class methods.
Sorbet won’t stop you, so you’ll have to stop yourself.
What should I use instead?
There are some alternatives to abstract singleton class methods. They all involve a certain amount of refactoring, and there isn’t always a best one. The options:
Define an interface or abstract module, declare the abstract method on it, and
extend
the interface into the concrete classes. Ideally: replace the abstract parent class with this interface entirely.Note how
example
usesHasFoo
instead ofT.class_of(AbstractParent)
, and how theextend
is in the child class—there isn’t even an abstract parent class anymore.This is the best option when a class’s only abstract methods are singleton class methods. If you want to look at a more realistic example, there’s an involved one in the Sorbet docs.
Define the abstract module inside a module that is mixed into another module using mixes_in_class_methods.
This is the best option if a class has both abstract instance and singleton class methods. It has the same downside as above, in that it involves refactoring some types.
Make the method
overridable
instead ofabstract
, effectively giving the method a default implementation.This option does not need as big of a refactor, because it does not introduce any new types or interfaces. But there isn’t always a sensible default implementation, so sometimes this option doesn’t it can’t .
What do other languages do?
Basically every other typed language correctly avoids this pitfall. For example:
Scala
object
definitions (analogous to Ruby’s singleton classes) cannot have abstract methods, because the object is instantiated (just like Ruby singleton classes).In Java and C++, the analogue to singleton class methods are
static
methods. As the name implies, these methods use static dispatch instead of dynamic dispatch. Abstract methods are only useful in combination with dynamic dispatch, so these languages simply banabstract static
methods.Despite also using the
static
keyword, TypeScriptstatic
methods use dynamic dispatch. But TypeScript recognizes that the object representing a class always exists at runtime, so it also bansstatic abstract
methods.
Of course, in these languages there isn’t the same rich link between a class and its singleton class, so the comparison to other languages is a bit shallow.
Why have abstract singleton class methods at all?
We noticed a ton of code that looked like this when rolling out Sorbet in Stripe’s codebase many years ago:
class AbstractParent
def self.foo
raise NotImplementedError.new("Missing implementation of foo")
end
end
Allowing existing methods like foo
to be abstract at
least requires subclasses to implement them or mark themselves
abstract!
. It’s much better than the alternative above of
just raising hoping that tests discover unimplemented methods.
There’s an escape hatch for code which chooses to use abstract singleton class methods (in spite of all their problems). It’s possible to manually check whether a class object is abstract before calling the method:
sig { params(klass: T.class_of(AbstractParent)) }
def example(klass)
if T::AbstractUtils.abstract_module?(klass)
return
end
klass.foo
end
example(ConcreteChild)
example(AbstractParent) # early return, before klass.foo
This abstract_module?
method doesn’t solve the original
problem; nothing in Sorbet checks that it’s called before calling an
abstract singleton class method. But it at least lets authors work
around known shortcomings in their code’s design without large
refactors. Know that from the type system’s perspective, all the
three options above are better than relying on
abstract_module?
checks at runtime.
Further reading
Inheritance in Ruby, in pictures →
A solid understanding of abstract methods requires understanding how Ruby’s inheritance features work (<
,include
, andextend
).Typing klass.new in Ruby with Sorbet →
If the method you’re trying to make abstract is a class’s constructor, there’s some subtlety to it.Every type is defined by its intro and elim forms →
For some high level thoughts on type-driven code organization.