Do::All does not work inside singleton classes

Hello again! I’ve found another piece of quirky behaviour inside Do.

Consider this example: (fuller example with tests)

module ConvertToUpperCase
  class SingletonDoAllShortcut
    class << self
      include Dry::Monads[:do, :try]

      def call(string)
        yield to_upper(string)
      end

      private

      def to_upper(s)
        Try { String(s).upcase }
      end
    end
  end
end

ConvertToUpperCase::SingletonDoAllShortcut.call("pow!") # => LocalJumpError (no block given)

Do::All does not behave as expected when invoked inside a singleton class. For whatever reason, the methods are not detected by Do::All and so aren’t wrapped. When the code is invoked, the developer just sees LocalJumpError exceptions. This was super-confusing when I first encountered it: it took me a while to believe the issue was with dry-rb and not my development environment!

As demonstrated in the fuller example:

include Dry::Monads[:do] in the singleton class
include Dry::Monads::Do::All in the singleton class
include Dry::Monads::Do.for in the singleton class
include All in the main class, and call .new.call in the singleton class


Given the wider conventions outlined by dry-container & dry-system (Klass.new.call is preferred over Klass.call), I wouldn’t be surprised if this is a WONTFIX issue - especially if a singleton-class wrapper is such a simple workaround!

I’d like to contribute to the documentation so this behaviour is less surprising (or at least easily-discoverable). How can I best do that?

1 Like

When using class methods, you should try to extend, not include:

module ConvertToUpperCase
  class SingletonDoAllShortcut
    extend Dry::Monads[:do, :try]
    class << self


      def call(string)
        yield to_upper(string)
      end

      private

      def to_upper(s)
        Try { String(s).upcase }
      end
    end
  end
end

ConvertToUpperCase::SingletonDoAllShortcut.call("pow!")
# => POW!

I’d like to contribute to the documentation so this behaviour is less surprising (or at least
easily-discoverable). How can I best do that?

It’s documented on https://dry-rb.org/gems/dry-monads/1.3/do-notation/ down in the bottom. Perhaps it needs a more in deep documentation (or FAQ) since it’s kind of frequent misunderstanding as you can see in this issue at GitHub:

https://github.com/dry-rb/dry-monads/issues/132

2 Likes

As soon as you mentioned extend I went “of course! That makes sense”.

Where I think a bunch of people (myself included) are getting confused is that Do is the only monad that requires extend:


class X
  class << self
    include Dry::Monads[:result, :maybe, :try, :task]

    def result
      Success(:z)
    end

    def maybe
      None()
    end

    def try
      Try { raise "BOOM" }
    end

    def task
      Task { sleep 2; :ok }
    end
  end
end

p X.result    # => Success(:z)
p X.maybe     # => None
p X.try       # => Try::Error(RuntimeError: BOOM)
p X.task.wait # Task(value=:ok)

I guess this is probably because Ruby can tell the Type Classes are in scope regardless, whereas Do is messing around with methods? Anyone who starts by including rather than extending and picks a monad will have it work, only to break mysteriously when they add :do.

What can we do to make this less surprising?

1 Like