I'm getting the following error in Sorbet:
lib/guardian.rb:24: Dynamic constant references are unsupported https://srb.help/5001
24 | self.class::MIN_AUTH || raise("Minimum auth must be specified")
The Guardian class has the following structure
class Guardian
MIN_AUTH = AuthLevel.new(:STRONG)
# ...
def min_auth
self.class::MIN_AUTH || raise("Minimum auth must be specified")
end
end
It is designed to so that it can change on child objects. It's also designed such that if it's not specified on a child object we hear about it. I'm interested to know why such a design pattern is (implied) bad practice. Should I skip the constant and just define it in the helper method instead: get_min_auth
?
Should I skip the constant and just define it in the helper method instead:
get_min_auth
?
Yep, that's exactly it.
# typed: true
class AuthLevel < T::Struct
const :level, Symbol
end
class Guardian
def get_min_auth
AuthLevel.new(level: :STRONG)
end
def min_auth
get_min_auth || raise("Minimum auth must be specified")
end
end
To really drive home why using methods are better here: you can use an abstract method to require that child classes implement the method. There's no way to require that child classes define a constant. This would make your entire example basically go away, because you would no longer need to raise
. It would turn into a static type error:
# typed: true
class AuthLevel < T::Struct
const :level, Symbol
end
class Guardian
extend T::Sig
extend T::Helpers
abstract!
sig {abstract.returns(AuthLevel)}
def min_auth
end
end
class MyGuardian < Guardian
end
I'm interested to know why such a design pattern is (implied) bad practice.
Sorbet works in phases. First, it learns about all classes/modules/constants in a codebase. Then, it learns about all methods on those classes/modules. Then it learns about the types on those methods. And finally, it learns about the types of local variables in those methods.
When Sorbet is looking to see whether (...)::MIN_AUTH
is a constant that actually exists, it knows nothing except the constants that have been defined so far, not the methods and not the local variables. self
is essentially a local variable, and .class
is a method that could have been overridden on a child class. Since it knows about neither local variables nor methods, it reports a dynamic constant reference. self.class
is an "arbitrary expression," not a static constant.
So maybe the next question is: why does Sorbet impose this seemingly arbitrary ordering of resolving constants first? The two biggest reasons:
Speed. It requires less analysis to say "this is a dynamic constant reference" and ask the programmer to refactor the code than it does to allow cyclic references. Given that there's a relatively easy refactoring (that you mention), this seems like a worthwile tradeoff to make every subsequent type check run faster.
Readability. self.class::MIN_AUTH
is essentially dynamic dispatch via Ruby's constant resolution algorithms. And in fact, constant resolution is in many ways harder to understand than method resolution, because it's affected by both module nesting and the inheritance hierarchy (whereas method lookup is only affected by inheritance). Relying on a complex lookup and dispatch is harder to read than just using methods, which people are more familiar with (especially coming from other languages to Ruby).