Abstract singleton class methods are an abomination

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) where Foo 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(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:

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

    module HasFoo
      abstract!
      sig { abstract.void }
      def foo; end
    end
    
    class ConcreteChild
      extend HasFoo
      sig { override.void }
      def self.foo = puts("hello!")
    end
    
    sig { params(HasFoo) }
    def example(klass)
      klass.foo
    end
    
    example(ConcreteChild)  # ✅
    example(HasFoo)         # ❌ static type error prevents this

    View on sorbet.run →

    Note how example uses HasFoo instead of T.class_of(AbstractParent), and how the extend 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.

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

    mixes_in_class_methods example →

  3. Make the method overridable instead of abstract, 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:

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(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