Search code examples
rubysorbet

Why does this Sorbet error count as a dynamic constant reference?


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?


Solution

  • 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
    

    → View on sorbet.run

    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
    

    → View on sorbet.run


    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).