Abstract Classes and Interfaces
Sorbet supports abstract classes, abstract methods, and interfaces. Abstract methods ensure that a particular method gets implemented anywhere the class or module is inherited, included, or extended. An abstract class or module is one that contains one or more abstract methods. An interface is a class or module that must have only abstract methods.
Keep in mind:
abstract!can be used to prevent a class from being instantiated.- Both
abstract!andinterface!allow the class or module to haveabstractmethods. - Mix in a module (via
includeorextend) to declare that a class implements an interface.
Note: Most of the abstract and override checks are implemented statically, but some are still only implemented at runtime, most notably variance checks.
Creating an abstract method
To create an abstract method:
- Add
extend T::Helpersto the class or module (in addition toextend T::Sig). - Add
abstract!orinterface!to the top of the class or module. (All methods must be abstract to useinterface!.) - Add a
sigwithabstractto any methods that should be abstract, and thus implemented by a child. - Declare the method on a single line with an empty body.
module Runnable
extend T::Sig
extend T::Helpers # (1)
interface! # (2)
sig { abstract.params(args: T::Array[String]).void } # (3)
def main(args); end # (4)
end
Implementing an abstract method
To implement an abstract method, define the method in the implementing class or module with an identical signature as the parent, except replacing abstract with override.
class HelloWorld
extend T::Sig
include Runnable
# This implements the abstract `main` method from our Runnable module:
sig { override.params(args: T::Array[String]).void }
def main(args)
puts 'Hello, world!'
end
end
Additional Requirements
There are some additional stipulations on the use of abstract! and interface!:
- All methods in a module marked as
interface!must have signatures, and must be markedabstract.- Note: this applies to all methods defined within the module, as well as any that are included from another module
- A module marked
interface!can’t haveprivateorprotectedmethods. - Any method marked
abstractmust have no body.sorbet-runtimewill take care to raise an exception if an abstract method is called at runtime. - Classes without
abstract!orinterface!must implement allabstractmethods from their parents. extend MyAbstractModuleworks just likeinclude MyAbstractModule, but for singleton methods.abstract!classes cannot be instantiated (will raise at runtime).
Why choose interface! over abstract!?
Apart from the differences listed above, the choice of whether to use interface! or abstract! comes down to preference and circumstance.
An example where the difference matters: people sometimes use interface! as the starting point for extracting logic across a network boundary into a new service. In this case, having implementation logic in the interface would complicate an in-progress migration: the logic should either live in the soon-to-be-extracted service, or the current service.
Similarly, interface! is a way to prevent accidental dependencies. Ruby applications developed in monorepos can use static analysis to list the files which must be included in a service’s deploy artifact or be preloaded at deploy time. (Tangled code leads a service to include more files than might be strictly necessary.) When these analyses use Ruby constant resolution-based algorithms, using an interface! guarantees that depending on the interface does not necessarily incur a dependency on some part of the implementation. This makes it easier to detangle and modularize dependencies.
In cases like these, the line with the interface! declaration serves as a great point to capture context around why the given module is the way it is in a comment. If no such context exists, and the other differences mentioned above are okay for the use case, it’s usually fine to switch from interface! to abstract!.
overridable: Providing default implementations of methods
Certain abstract classes or interfaces want to provide methods that provide a reasonable default implementation of a method, allowing individual children to override the method with a more specific implementation.
This is done with overridable:
module Countable
extend T::Helpers
# 1: `abstract!` instead of `interface!`
abstract!
sig { abstract.returns(T.nilable(Integer)) }
def to_count; end
# 2: Use `overridable` to provide default implementation of `to_count!`
sig { overridable.returns(Integer) }
def to_count!
T.must(self.to_count)
end
end
As the example shows, there are two main steps:
If the module is not already
abstract!(i.e., if it’s aninterface!), change it to useabstract!. Modules declared withinterface!are constrained to only have abstract methods, which prevents adding methods with a default implementation.Use
overridableto declare the default implementation of a method. Usingoverridableopts the method into static override checking, which will ensure that children define a type-compatible override.
Note: if you want to provide functionality in an abstract class or module that must not be possible to override in a child, use a final method.
Letting abstract methods be implemented via inheritance
Sorbet allows abstract methods in modules to be implemented by an ancestor of the class or module they’re eventually mixed into. Consider this example:
class Parent
sig { void }
def foo = puts 'Hello!'
end
module IFoo
extend T::Helpers
abstract!
sig { abstract.void }
def foo; end
end
class Child < Parent # ✅ okay
include IFoo
end
class NotAParent # ❌ Missing definition for `foo`
include IFoo
end
Breaking down this example:
The
IFoomodule declares a single, abstractfoomethod. All classes that include this module must either be marked abstract or define this method.The
Parentmethod does not depend onIFoo, but does happen to define a method calledfoo.Both
ChildandNotAParenthaveinclude IFoo, but neither define afoomethod.Despite this: only
NotAParenthas an error saying that a concrete implementation offoois missing. Sorbet allows thefoomethod to be implemented inChildbecause it inherits afoomethod fromParent.
Approximating duck types
This technique is particularly useful as a way to approximate “duck typing,” where you depend on “anything type, so long as it has this method.”
For example:
module ShortName
extend T::Helpers
abstract!
sig { abstract.returns(T.nilable(String)) }
def name; end
sig { returns(T.nilable(String)) }
def short_name
self.name&.split('::')&.last
end
end
This module provides a short_name method (which computes the “short name” of a something like a Module by splitting the full name into ::-delimited tokens and returning the last one. Like C for module A::B::C).
The module’s implementation depends on the name method existing. If we don’t declare it as an abstract method, Sorbet reports an error saying “Method name does not exist,” which is true–there’s no guarantee someone mixes this module into a context where name is defined.
But by declaring name as an abstract method, Sorbet will check this property. In particular, this has the effect of catching someone who accidentally uses include instead of extend when mixing this module into a class:
class A
include ShortName # ❌ error: Must define abstract method `name`
end
class B
extend ShortName # ✅
end
This technique is particularly effective when it’s not possible to refactor some upstream dependency’s code to expose an explicit interface. The Module class in the Ruby stdlib doesn’t have some sort of public INameable interface with the name method. A handful of database model classes in an application might share a set of related fields, without explicitly implementing some interface. And yet, using this technique Sorbet allows writing modules which depend on those implicit interfaces.
This technique is also quite flexible: the ShortName module can be used in any context where a name method is available. So for example, if you had some T::Struct that stores a name, this ShortName mixin could also be used:
class C < T::Struct
include ShortName
prop :name, String, override: :reader
end
C.new(name: "Some::Long::Namespace").short_name # => "Namespace"
Consequences for runtime signature checking
Letting abstract methods be implemented by inherited methods relies on the fact that method signatures are checked at runtime. To explain why this feature requires runtime support, let’s look at the resolved ancestors of Child:
irb> Child.ancestors
=> [Child, IFoo, Parent, <...>]
This shows that Ruby resolves a call like child.foo by first checking whether Child defines foo, then whether IFoo defines foo, and then finally whether Parent does. Since it looks in IFoo before Parent, Ruby actually calls the IFoo#foo method. But this method would normally have an empty method body—it’s abstract!
So at runtime, the sig method replaces the implementation of foo with a method that does something like this:
def foo
if defined?(super)
super
else
raise NotImplementedError.new("Call to unimplemented abstract method")
end
end
This allows IFoo#foo to dispatch up the ancestor chain, letting child.foo result in a call to Parent#foo.
If runtime signature checking is disabled, a call like child.foo will silently produce nil instead of calling the appropriate method.
Abstract singleton methods
abstract singleton methods on a module are not allowed, as there’s no way to implement these methods.
module M
extend T::Sig
extend T::Helpers
abstract!
sig { abstract.void }
def self.foo; end
end
M.foo # error: `M.foo` can never be implemented
Abstract singleton methods on a class are allowed, but are unsound (i.e., they can lead to runtime, type-related exceptions like TypeError and NameError even when there is no T.untyped involved):
class AbstractParent
abstract!
sig { abstract.void } # ❌ BAD: abstract singleton class method!
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) # ✅ okay
example(AbstractParent) # static: ✅ no errors
# runtime: 💥 call to abstract method foo
For more information, see this blog post:
Abstract singleton class methods are an abomination →
The blog post above discusses the problem and three alternatives to avoid using abstract singleton class methods. To summarize:
Declare an interface or abstract module with abstract instance methods, and
extendthat module onto a class.Use the above approach, but with
mixes_in_class_methods, discussed below.Make the method
overridableinstead ofabstract, effectively giving the method a default implementation.
There are also some runtime escape hatches to work around this problem. See Runtime reflection on abstract classes below.
Interfaces and the included hook
A somewhat common pattern in Ruby is to use an included hook to mix class methods from a module onto the including class:
module M
module ClassMethods
def foo
self.bar
end
end
def self.included(other)
other.extend(ClassMethods)
end
end
class A
include M
end
# runtime error as `bar` is not defined on A
A.bar
This is hard to statically analyze, as it involves looking into the body of the self.included method, which might have arbitrary computation. As a compromise, Sorbet provides a new construct: mixes_in_class_methods. At runtime, it behaves as if we’d defined self.included like above, but will declare to Sorbet statically what module is being extended.
We can update our previous example to use mixes_in_class_methods, which lets Sorbet catch the runtime error about bar not being defined on A:
# typed: true
module M
extend T::Helpers
interface!
module ClassMethods
extend T::Sig
extend T::Helpers
abstract!
sig { void }
def foo
bar
end
sig { abstract.void }
def bar; end
end
mixes_in_class_methods(ClassMethods)
end
class A # error: Missing definition for abstract method
include M
extend T::Sig
sig { override.void }
def self.bar; end
end
# Sorbet knows that `foo` is a class method on `A`
A.foo
We can also call mixes_in_class_methods with multiple modules to mix in more methods. Some Ruby modules mixin more than one module as class methods when they are included, and some modules mixin class methods but also include other modules that mixin in their own class modules. In these cases, you will need to declare multiple modules in the mixes_in_class_methods call or make multiple mixes_in_class_methods calls.
For a more comprehensive resource on how mixes_in_class_methods builds on existing Ruby inheritance features, see this blog post:
Inheritance in Ruby, in pictures →
Runtime reflection on abstract classes
From time to time, it’s useful to be able to ask whether a class or module object is abstract at runtime.
This can be done with
sig { params(mod: Module).void }
def example(mod)
if T::AbstractUtils.abstract_module?(mod)
puts "#{mod} is abstract"
else
puts "#{mod} is concrete"
end
end
Note that in general, having to ask whether a module is abstract is a code smell. There is usually a way to reorganize the code such that calling abstract_module? isn’t needed. In particular, this happens most frequently from the use of modules with abstract singleton class methods (abstract self. methods), and the fix is to stop using abstract singleton class methods.
Here’s an example:
# typed: true
# --- This is an example of what NOT to do ---
extend T::Sig
class AbstractFoo
extend T::Sig
extend T::Helpers
abstract!
sig { abstract.void }
def self.example; end
end
class Foo < AbstractFoo
sig { override.void }
def self.example
puts 'Foo#example'
end
end
sig { params(mod: T.class_of(AbstractFoo)).void }
def calls_example_bad(mod)
# even though there are no errors,
# the call to mod.example is NOT always safe!
# (see comments below)
mod.example
end
sig { params(mod: T.class_of(AbstractFoo)).void }
def calls_example_okay(mod)
if !T::AbstractUtils.abstract_module?(mod)
mod.example
end
end
calls_example_bad(Foo) # no errors
calls_example_bad(AbstractFoo) # no static error, BUT raises at runtime!
calls_example_okay(Foo) # no errors
calls_example_okay(AbstractFoo) # no errors, because of explicit check
In the example above, calls_example_bad is bad because mod.example is not always okay to call, despite Sorbet reporting no errors. In particular, calls_example_bad(AbstractFoo) will raise an exception at runtime because example is an abstract method with no implementation.
An okay, but not great, fix for this is to call abstract_module? before the call to mod.example, which is demonstrated in calls_example_okay.
Most other languages simply do not allow defining abstract singleton class methods (for example, static methods in TypeScript, C++, Java, C#, and more are not allowed to be abstract). For historical reasons attempting to make migrating to Sorbet easier in existing Ruby codebases, Sorbet allows abstract singleton class methods.
A better solution is to make an interface with abstract methods, and extend that interface into a class:
# typed: true
extend T::Sig
module IFoo
extend T::Sig
extend T::Helpers
abstract!
sig { abstract.void }
def example; end
end
class Foo
extend T::Sig
extend IFoo
sig { override.void }
def self.example
puts 'Foo#example'
end
end
sig { params(mod: IFoo).void }
def calls_example_good(mod)
# call to mod.example is always safe
mod.example
end
calls_example_good(Foo) # no errors
calls_example_good(IFoo) # doesn't type check
In this example, unlike before, we have a module IFoo with an abstract instance method, instead of a class AbstractFoo with an abstract singleton class method. This module is then extend'ed into class Foo to implement the interface.
This fixes all of our problems:
- We no longer need to use
abstract_module?to check whethermodis abstract. - Sorbet statically rejects
calls_example_good(IFoo)(intuitively: becauseIFoo.exampleis not a method that even exists).
Another benefit is that now we have an explicit interface that can be documented and implemented by any class, not just subclasses of AbstractFoo.
What’s next?
-
Sorbet has more ways to check overriding than just whether an abstract method is implemented in a child. See this doc to learn about the ways to declare what kinds of overriding should be allowed.
-
Abstract classes and interfaces are frequently used with sealed classes to recreate a sort of “algebraic data type” in Sorbet.