Problem: you’re using Sorbet, and there’s a module or interface whose implementation depends on an instance variable having been initialized by the class that it’s eventually mixed into.
Solution: make the interface depend on an abstract method instead (not an instance variable). Replace references of the instance variable in the module with the abstract method, and implement the abstract method by producing the instance variable.
Setup
Let’s say we’ve got some code like this, modeling a hierarchy of different kinds of users:
class User
def initialize(user_id)
@user_id = user_id
end
end
class RegularUser < User; end
class AdminUser
include Auditable
end
module Auditable
def with_audit_log(action)
puts("user=#{@user_id} starting action #{action}")
yield
end
end
We might want to make a with_audit_log
helper method that we include in certain kinds of users that will do a lot of auditable actions. In that module we know that the @user_id
method exists (because we’re only going to include it in contexts where it exists), but Sorbet doesn’t know that.
Solution
Sorbet does not have a concept of abstract instance variables.This is mostly for simplicity—I’m not aware of any appeal to soundness why it could not gain them one day. Many other object-oriented languages do not make a distinction between methods and instance variables. For example, Scala traits can have abstract val
declarations the same as it can have abstract def
declarations.
To work around this, we need to:
- define an abstract method for the instance variable
- update usages of the instance variable to the method
- implement the abstract method
This way Sorbet can check that all the required information is present every time we include the module somewhere.
module Auditable
extend T::Helpers
abstract!
sig { abstract.returns(UserID) } # (1)
private def user_id; end
def with_audit_log(action)
puts("user=#{user_id} starting action #{action}") # (2)
yield
end
end
class User
# ...
private attr_reader :user_id # (3)
end
When we include this into the AdminUser
class, Sorbet knows that the user_id
method exists, because it came from the parent User
class—no change to AdminUser
is required, because Sorbet allows implementing abstract methods via inheritance.
If someone attempts to include this interface somewhere else, Sorbet will report an error:
module NotAUser # error: Missing definition for abstract method
include Auditable
end
Note: the user_id
methods are private
, which prevents people from calling it directly. That mimics how instance variables work (private methods and instance variables have the same visibility rules). This is optional: if you’d like the method to be public, make it public.
Worse alternatives
There are some worse ways to solve this problem, and I want to take the time to point them out and also why they aren’t as good.
Declaring the instance variable’s type with T.let
Something like this can be tempting:
module Auditable
{ returns(UserID) }
sig private def user_id
@user_id ||= T.let(@user_id, T.nilable(UserID))
.must(@user_id)
Tend
def with_audit_log(action)
puts("user=#{user_id} starting action #{action}")
yield
end
end
In this example, we use ||= T.let
to declare @user_id
to @user_id
, relying on the fact that instance variables that have not yet been assigned will evaluate to nil
.
This is worse because Sorbet does not allow instance variables to be declared non-nil
outside of the constructor—the abstract method approach allows declaring non-nil
types.
It’s also worse because it doesn’t actually get Sorbet to check that whatever class Auditable
is mixed into actually has a user_id
field. That is: the T.must
might fail at runtime!
Using requires_ancestor
First off, the experimental requires_ancestor
feature only applies to methods, not instance variables.
Even if that were changed, requires_ancestor
is anti-modular: if you ever want to use the interface with some other ancestor that provides a user_id
, you’d need to edit the definition of Auditable
to mention both T.any(User, ThatOtherClass)
.
In some sense, using abstract methods like this achieves a sort of duck typing. It doesn’t matter which class provides the method: as long as it’s called user_id
and it has the right type, this module can be mixed into anything.
(Ultimately, requires_ancestor
itself is experimental because the approach itself is problematic.)
Appendix: What about “self-contained” instance variables?
If the module defines its own instance variables, e.g. for the purpose of caching some sort of state, there should be no problem. Just use ||=
like normal:
{ returns(Integer) }
sig def foo
@foo ||= T.let(compute_foo, T.nilable(Integer))
end
(Things only get problematic when a module expects an instance variable to have already been set outside of its own logic.)
Note that in this case, as long as compute_foo
returns Integer
, the foo
method can also return Integer
without needing T.must
—Sorbet knows that if the value is nil
, it will be computed with compute_foo
, so the method unconditionally returns a non-nil
value.