dry-monads: flaws or intended behaviour?

Hi everyone, first post here, thanks for your incredible work with the dry ecosystem.

I created this topic because I’ve been using dry-monads at my job for 2 years now but think there are two edge cases which really impact how I wrote the code.

First of all, using yield and rescue in the same method breaks the Do Notation because it catches Dry::Monads::Do::Halt instead of the real error type.

def my_method
  yield Failure('error code')
rescue StandardError => e
  # e is an instance of Dry::Monads::Do::Halt
  Failure(e)
end

Similarly, it’s not possible to use yield inside Try because it returns Failure(Dry::Monads::Do::Halt)

def my_method
  Try do
     yield Failure('error code')
  end.to_result
end

correct me if I’m wrong but I’ve not read anything about this in the official documentation.
Is this the intended behaviour? If yes, why?

This is an intentional design choice to support transaction rollbacks, and altering the behavior would be a breaking change. Exceptions as flow-control is considered an antipattern, and Monads are considered an alternative to that practice.

There still is a need to adapt exception-laden code when wrapping them in monads, though.

You can work around this in a couple ways. The Try monad is probably the best approach.

Alternately, you can reraise Halt

def my_method
  yield Failiure('error code')
rescue Dry::Monads::Do::Halt
  raise
rescue => err
  Failure(err)
end

I can’t think of any reason why you would need to use yield inside a Try monad. The purpose is to act as a boundary between exceptional code and monadic code. Simply moving the yield outside of the Try block should work fine

yield Try { raise 'something went wrong' }.to_result

Some times, using yield is inconvenient for various reasons, and in those situations you can use the bind interface directly.

Dry::Monads::Do.bind Failure('error code')

Basically, your code is responsible for transforming what it is doing into a monadic type, and then you hand it off to yield. So for example, every Operation object that I use has this helper available:

# Utility helper to avoid .to_monad.or {} chaining
#
# @param [#to_monad] result, Dry::Monads::Result or object that responds to `to_monad`
# @param [Dry::Monads::Result::Failure, #to_proc, { :error => Symbol }] error
#
# @example With Yield
#   user = yield fetch_user.(id).or { |err| Failure[:not_found, err] }
#
# @example With Either
#   user = either fetch_user.(id), Failure(:not_found)
#   user = either fetch_user.(id), ->(err) { Failure[:not_found, err] }
#   user = either fetch_user.(ud), error: :not_found
#
# @raise [Dry::Monads::Do::Halt]
#
# @return the unwrapped successful value
def either(result, error)
  failure =
    case error
    in { error: error_name }
      proc { |err| Failure[error_name, err] }
    in T::Procable
      error
    else
      proc { error }
    end

  Dry::Monads::Do.bind result.to_monad.or(&failure)
end
1 Like

Thanks @alassek for the response and the clear explaination. I’ve read the whole discussion of the issue you linked and then I ended up reading other issues where people explain that it could be “fixed” by catching Exception instead of StandardError with a few breaking changes but the author doesn’t seem to be happy with this solution.
While I understand your point of view I still prefer to have a “consistent” behaviour, being able to rescue errors without taking care about Do::Halt.

So I ended up writing a (very) small and simple wrapper over dry-monads to handle errors the way I like: to-result