rubygenericsinterfacesorbet

Sorbet Interface requires implementation signature types to be equal to or an ancestor of the type instead of a descendant


The following sample code results in a srb error with note: A parameter's type must be a supertype of the same parameter's type on the super method.

editor.rb:29: Block parameter _block of type T.proc.params(arg0: T.class_of(Admin::PostAuthAction::PostActionContext)).returns(Admin::PostAuthAction::PostActionContext) not compatible with type of abstract method Lifecycle::PostAuthAction#sync https://srb.help/5035
    29 |    def sync(&_block)
            ^^^^^^^^^^^^^^^^^
    editor.rb:11: The super method parameter _block was declared here with type T.proc.params(arg0: T.class_of(T::Struct)).returns(T::Struct)
    11 |    def sync(&_block); end
            ^^^^^^^^^^^^^^^^^
  Note:
    A parameter's type must be a supertype of the same parameter's type on the super method.
# typed: strict

module Lifecycle
  module PostAuthAction
    extend T::Sig
    extend T::Helpers

    interface!

    sig do
      abstract.params(
        _block: T.proc.params(arg0: T.class_of(T::Struct)).returns(T::Struct)
      ).void
    end
    def sync(&_block); end
  end
end

module Admin
  class PostAuthAction
    include Lifecycle::PostAuthAction
    extend T::Sig

    class PostActionContext < T::Struct
      const :user, Object
    end

    PostActionContextCallback = T.type_alias do
      T.proc.params(arg0: T.class_of(PostActionContext)).returns(PostActionContext)
    end

  
    sig { override.params(_block: PostActionContextCallback).void }
    def sync(&_block)
      context = yield(PostActionContext)
    end
  end
end

My expectation is that the interface should define the upper bound wherein the signature of the interface expects a block which accepts a Class T::Struct and returns an instance of T::Struct.

The implementation provides a subclass of T::Struct and results in this typing error. The interface instead defines the lower bound of the inheritance and I can only provide Ancestors of T::Struct instead of descendants.

What gives?

I am able to accomplish the expected LSP (Liskov substitution) if I add generics. Here is a plaground for the code above, and a solution using generics


Solution

  • Sorbet is right in complaining about the original implementation: the child class can't override a method to receive a more specific type than the parent.

    To see why this is a problem, look at the following example:

    class Food; end
    class Grass < Food; end
    class Meat < Food; end
    
    module Animal
      extend T::Sig
      extend T::Helpers
    
      interface!
    
      sig do
        abstract.params(food: Food).void
      end
      def eat(food); end
      end
    end
    
    class Elephant
      include Animal
    
      # Sorbet doesn't allow this, which shows that my modeling is wrong:
      # I can't make `Animal` eat `Food` if then I want some animals to
      # be herbivores!
      sig do
        override.params(food: Grass).void
      end
      def eat(food); end
    end
    
    # Now, let's suppose that I ignore the type-checking Sorbet does:
    
    sig {params(animal: Animal, food: Food}
    def feed(animal, food)
      animal.eat(food) # Here, I could be giving meat to a non-meat-eater!
    end
    
    feed(Elephant.new, Meat.new)
    

    The key is to follow the Robustness principle: "be conservative in what you send, be liberal in what you accept". When dealing with Sorbet signatures, this means: