Typing instance variables in mixins

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:

  1. define an abstract method for the instance variable
  2. update usages of the instance variable to the method
  3. 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    # (3)
end
View full example on sorbet.run →

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
  sig { returns(UserID) }
  private def user_id
    @user_id ||= T.let(@user_id, T.nilable(UserID))
    T.must(@user_id)
  end

  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:

sig { returns(Integer) }
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.