Can I specify a duck type in method signatures?
Asked Answered
C

1

9

Here's example code:

# typed: true

class KeyGetter

  sig {params(env_var_name: String).returns(KeyGetter)}
  def self.from_env_var(env_var_name)
    return Null.new if env_var_name.nil?

    return new(env_var_name)
  end

  def initialize(env_var_name)
    @env_var_name = env_var_name
  end

  def to_key
    "key from #{@env_var_name}"
  end

  def to_s
    "str from #{@env_var_name}"
  end

  class Null
    def to_key; end
    def to_s; end
  end
end

Running srb tc on it fails with

key_getter.rb:7: Returning value that does not conform to method result type https://srb.help/7005
     7 |    return Null.new if env_var_name.nil?
            ^^^^^^^^^^^^^^^
  Expected KeyGetter
    key_getter.rb:6: Method from_env_var has return type KeyGetter
     6 |  def self.from_env_var(env_var_name)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  Got KeyGetter::Null originating from:
    key_getter.rb:7:
     7 |    return Null.new if env_var_name.nil?
                   ^^^^^^^^

I see several ways of working around this:

  1. Use something like .returns(T.any(KeyGetter, KeyGetter::Null)) in the sig.
  2. Make KeyGetter::Null inherit from KeyGetter.
  3. Extract an "interface" and expect that.

    class KeyGetter
      module Interface
        def to_key; end
        def to_s; end
      end
    
      class Null
        include KeyGetter::Interface
      end
    
      include Interface
    
      sig {params(env_var_name: String).returns(KeyGetter::Interface)}
      def self.from_env_var(env_var_name)
        return Null.new if env_var_name.nil?
    
        return new(env_var_name)
      end
    

But what I'd like to know (and didn't find in the docs) is: can I describe the duck type? Like we can do in YARD, for example:

 # @returns [(#to_s, #to_key)]

Or is it an inherently flawed idea (because ideally we need to annotate the duck type's methods too. And not get lost in syntax while doing that).

So yes, can we annotate the duck type inline here? If not, what should we do instead?

Cawley answered 21/6, 2019 at 12:26 Comment(10)
Third option would be to guard clause KeyGetter#to_key and KeyGetter#to_s as return unless @env_var_name then Null class is not technically needed. However I think that inheritance or abstraction is your best bet because I assume that the return value may be passed on to other method calls where the type is specified as well thus KeyGetter and Null would need to be considered the same type.Hormuz
@SergioTulentsev : While we have "duck typing", there is no such thing as a "duck type". You can of course check whether your arguments respond to a certain method, and throw an exception if they don't.Appleton
@engineersmnky: well sure, we probably can find ways of getting rid of Null here. It's only an illustration, though. Imagine another case: a method accepts a #call (which returns, say, a User). So in production we pass a query object or something and in tests we can pass a Proc. Very useful.Cawley
@user1934428: for the lack of a better term, I came up with that myself, indeed. But you see what I mean, though? What would you call it?Cawley
@SergioTulentsev sure that's called ruby and one of the benefits is that it is not a static typed language. "Can't have your cake and eat it too" in this case. Just like ruby does not concern itself with typing, static typed languages do not concern themselves with the interface. sorbet looks like a cool idea because you can leverage both but I don't see a way to provide a signature for that (would be a cool addition but definitely not static typed anymore). The other issue I see with static typing ruby is that you cannot overload a method, which is how other languages handle this concern.Hormuz
@engineersmnky: well, let's wait for sorbet/stripe guys to wake up :)Cawley
C# solves this with 'Nullable Types', which are essentially wrappers for the normal types. You could design something similar in ruby.Unwell
@DaveMongoose: no, nullable types is something else entirely. Code in the question uses the Null Object pattern. And in my comment above you can find another example.Cawley
@SergioTulentsev : I think what you are looking for, is similar to Interfaces in Java, so we could call it an Interface type, or, borrowing from C++, a trait. But in any case, since we don't have such a facility hardwired in Ruby, you have to develop one of your own. Maybe something similar to define_method, but which also allows to specifiy for each parameter a set of methods it should respond to.Appleton
@user1934428: "there is no such thing as a "duck type"" - FWIW, YARD documentation calls it "duck types". So the thing not only is there, but people call it the same too.Cawley
R
2

But what I'd like to know (and didn't find in the docs) is: can I describe the duck type? Like we can do in YARD, for example:

I've found sorbet has very limited support for hash with specific keys (what flow calls "sealed object"). You could try something like this, but foo would be recognized as T::Hash[T.untyped, T.untyped], or at most T::Hash[String, String].

extend T::Sig

sig { returns({to_s: String, to_key: String}) }
def foo
  T.unsafe(nil)
end

T.reveal_type(foo)
foo.to_s
foo.to_key

See on Sorbet.run

They attempt to resolve that with Typed Struct ([T::Struct]), but that'd be no different from you defining the class/interface yourself.

Sorbet does support tuple but that wouldn't be ideal here either. See on Sorbet.run

Or is it an inherently flawed idea (because ideally we need to annotate the duck type's methods too. And not get lost in syntax while doing that).

Given that you want to annotate the duck type's methods, it's all the more to define a class for it. I like the option (2) the best among the approaches you outlined.

You can also make NULL a constant value instead. But given how the current code is implemented, it's probably not as good as option (2)

KeyGetter::NULL = KeyGetter.new(nil)
Radial answered 23/6, 2019 at 16:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.