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
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:
params
to be of the type, or more general (parent) than what's in the parent's signature.returns
only to be of the type, or a more specific (child) than what's in the parent's signature.