Code Monkey home page Code Monkey logo

sorbet-result's Introduction

Typed::Result, Typed::Success and Typed::Failure

A simple, strongly-typed monad for modeling results, helping you implement Railway Oriented Programming concepts in Ruby. Helps alleviate error-driven development, all the while boosting your confidence with Sorbet static checks.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add sorbet-result

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install sorbet-result

Usage

Getting Started

Using a basic Result in your methods is as simple as indicating something could return a Typed::Result.

In practice though, you won't return instances of Typed::Result, but rather Typed::Success or Typed::Failure.

Typed::Success can hold a payload, and Typed::Failure can hold an error, but if you don't have such information to provide you can simply use Typed::Success.blank or Typed::Failure.blank.

Typed::Result is powered by Sorbet's Generics, so you'll need to specify it as Typed::Result[Success, Error], where Success represents the type of payload in case of a success, while Error represents the type of error in case of an error.

sig { params(resource_id: Integer).returns(Typed::Result[NilClass, NilClass]) }
def call_api(resource_id)
  # something bad happened
  return Typed::Failure.blank

  # something other bad thing happened
  return Typed::Failure.blank

  # Success!
  Typed::Success.blank
end

Generally, it's nice to have a payload with results, and it's nice to have more information on failures. We can indicate what types these are in our signatures for better static checks. Note that payloads and errors can be any type.

Typed::Result, Typed::Success and Typed::Failure are all generic types, so you can specify the payload and error types when you use them.

Typed::Result will need both the success and the error types to be specified, while Typed::Success and Typed::Failure will only need the success or error type respectively.

Typed::Success.new and Typed::Failure.new are generic methods, so the payload or error type will be inferred by the parameter type.

sig { params(resource_id: Integer).returns(Typed::Result[Float, String]) }
def call_api(resource_id)
  # Something bad happened
  return Typed::Failure.new("I couldn't do it!") # => Typed::Failure[String]

  # Some other bad thing happened
  return Typed::Failure.new("I couldn't do it for another reason!") # => Typed::Failure[String]

  # Success!
  Typed::Success.new(1.12) # => Typed::Success[Float]
end

Note: We use Sorbet's generics for payload and error typing. The generic payload and error types are erased at runtime, so you won't get a type error at runtime if you violate the generic types. These types will help you statically so be sure to run srb tc on your project.

Further, if another part of your program needs the Result, it can depend on only Typed::Successes (or Typed::Failures if you're doing something with those results).

sig { params(success_result: Typed::Success[String]).void }
def do_something_with_resource(success_result)
  success_result.payload # => String
end

Finally, there are a few methods you can use on both Typed::Result types.

result = call_api(1)

result.success? # => true on success, false on failure
result.failure? # => true on failure, false on success
result.payload # => nil on failure, payload type on failure
result.error # => nil on success, error type on failure
result.payload_or("fallback") # => returns payload on success, given value on failure

# You can combine all the above to write flow-sensitive type-checked code
if result.success?
  T.assert_type!(result.payload, Float)
else
  T.assert_type!(result.error, String)
end

Chaining

Typed::Result supports chaining, so you can chain together methods that return Typed::Results using.

To do so, use the #and_then method to transform the payload of a Typed::Success into another Typed::Result, or return a Typed::Failure as is.

# In this example, retrieve_user and send_notification both return a Typed::Result
#  retrieve_user: Typed::Result[User, RetrieveUserError
#  send_notification: Typed::Result[T::Boolean, SendNotificationError]
res = retrieve_user(user_id)
  .and_then { |user| send_notification(user.email) } # this block will only run if retrieve_user returns a Typed::Success

# The actual type of `res` is Typed::Result[T::Boolean, T.any(RetrieveUserError, SendNotificationError)]
# because only the last operation can return a success, but any operation can return a failure.
if res.success?
  # Notification sent successfully, we can do something with res.payload coming from send_notification.
  res.payload # => T::Boolean
else
  # Something went wrong, res.error could be either from retrieve_user or send_notification
  res.error # => T.any(RetrieveUserError, SendNotificationError)
end

You can also use the #on_error chain to take an action only on failure, such as logging or capturing error information in an error monitoring service.

# In this example, retrieve_user and send_notification both return a Typed::Result
#  retrieve_user: Typed::Result[User, RetrieveUserError
#  send_notification: Typed::Result[T::Boolean, SendNotificationError]
res = retrieve_user(user_id)
  .and_then { |user| send_notification(user.email) } # this block will only run if retrieve_user returns a Typed::Success
  .on_error { |error| puts "Encountered this error: #{error}"}

If the above chain does not fail, the puts statement is never run. If the chain does yield a Failure, the puts block is executed and the Failure is ultimately returned.

Testing

We ship with a few Minitest assertions that can be used to easily verify Results.

# test_helper.rb

require "minitest/result_assertions"
# You also need add this to `sorbet/tapioca/require.rb` and rebuild the Minitest gem RBIs

# *_test.rb

@success = Typed::Success.new("Test Payload")
@failure = Typed::Failure.new("Test Error")

assert_success(@success)
assert_failure(@failure)
assert_payload("Test Payload", @success)
assert_error("Test Error", @failure)

# We also have the `refute_*` counterparts

Why use Results?

Let's say you're working on a method that reaches out to an API and fetches a resource. We hope to get a successful response and continue on in our program, but you can imagine several scenarios where we don't get that response: our authentication could fail, the server could return a 5XX response code, or the resource we were querying could have moved or not exist any more.

You might be tempted to use exceptions to model these "off-ramps" from the method.

sig { params(resource_id: Integer).returns(Float) }
def call_api(resource_id)
  # something bad happened
  raise ArgumentError

  # something other bad thing happened
  raise StandardError

  # Success!
  1.12
end

This has several downsides; primarily, this required your caller to know what exceptions to watch out for, or risk bubbling the error all the way out of your program when you could have recovered from it. Our Sorbet signature also becomes less helpful, as we can't indicate what errors could be raised here.

Railway Oriented Programming, which comes from the functional programming community, alleviates this by expecting the failure states and making them a part of the normal flow of a method. Most errors are recoverable; at the very least we can message them back to the user in some way. We should inform the caller with a Result that could be either a Success or Failure, and allow them continue or take an "off-ramp" with a failure message. If we embrace this style of programming, our call_api method turns into this:

sig { params(resource_id: Integer).returns(Typed::Result[Float, String]) }
def call_api(resource_id)
  # something bad happened
  return Typed::Failure.new("I couldn't do it!")

  # something other bad thing happened
  return Typed::Failure.new("I couldn't do it for another reason!")

  # Success!
  Typed::Success.new(1.12)
end

Sorbet is useful here now, as it the signature covers all possible return values and informs the caller what it should do: check the result status first, then do something with the error or payload. Statically, Sorbet will also know the types associated with our Result for better typechecking across the codebase.

Our caller doesn't need to guess which errors to rescue from (or doesn't need to be paranoid about rescuing all errors) and can proceed in both a success and a failure case.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake to run Rubocop and the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/maxveldink/sorbet-result. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in this project's codebase, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Sponsorships

I love creating in the open. If you find this or any other maxveld.ink content useful, please consider sponsoring me on GitHub.

sorbet-result's People

Contributors

imactia avatar maxveldink avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

Forkers

imactia

sorbet-result's Issues

Implement `on_error` chain on `Result`s

Thanks to @iMacTia 's awesome work on the and_then chain, we have a great composable way to work with Results. If a Failure is encountered, we end the chain (rather, perform no-ops the rest of the way) and return that Failure. It would be great if we could pass a block to be executed if a Failure is encountered (useful for error logging or other reporting). It's important that we pass the Failure back instead of the return type of the block, as we want to guarantee the same Result type as and_then or the original Result.

res = do_something
  .and_then { |success| Failure.new }
  .on_error { |failure| puts "An error" }

# prints "An error"
res # => Failure

Add `==` implementation for `Result` and descendants

Implementing the == method on results, successes, and failures would be nice, especially for test frameworks. I took an initial stab at this and was met with some generic variance errors. I would be happy to have a contribution for this if someone wants to give it a shot.

Implement default value for `#payload`

It would be nice to provide a default value to payload that is used if we're on a Failure type. I'm open to making this an optional argument to the current payload method or implementing it as a new method, perhaps named #payload_or. We should guarantee the type of the default value matches the Payload type.

Annotate methods with RDocs

We have a fairly extensive README that details how to use many of the features of Results, but we could benefit for some deeper comments and examples above each method.

Drop Ruby 3.0 support

Ruby 3.0 is now more than three years old and I'd like to target 3.1 as the minimum supported version.

Add Minitest assertion helpers

One pattern that's emerged as I've used Results in other projects is having to write Minitest assertions like this:

result = do_something

assert_predicate(result, :success?)
assert_equal("payload", result.payload)

I'd like to have an easier facility for this in minitest like:

assert_success(result)
assert_payload("payload", result)

Remove nilability wrapper from `Error` and `Payload`

Is there any reason why Typed::Failure and Typed::Success use T.nilable(Error) and T.nilable(Payload) internally?
Since those types are type_member, the user should be in control on making them nilable.
For example, if I define a method like this:

sig { returns(Typed::Failure[String]) }
def method_returning_failure
  # ...
end

I then need to deal with nilability on the caller side:

result = method_returning_failure
if result.error? 
  method_expecting_string(T.must(result.error))
end

The same issue exists for Payload, but in that case at least there's a payload! helper method which makes coding easier, but voids some type-checking assurances in the process and could lead to unexpected errors at runtime.
Removing the use of T.nilable, would allow the class to assume a value is always provided.
If for whatever reason the value needs indeed to be nilable, then we can simply specify that via the generics interface:

sig { returns(Typed::Failure[T.nilable(String)]) }
def method_returning_nilable_failure
  # ...
end

Would you consider adding flat mapping (aka chaining)?

There's a common concept in functional programming called flat-mapping or chaining that can help making code easier to follow in certain situations.

Take this example:

result1 = FirstService.new(...).call
final_result = if result1.success?
                 result2 = SecondService.new(result1.payload[...], ...).call
                 if result2.success?
                   ThirdService.new(result2.payload).call
                 else
                   result2
                 end
               else
                 result1
               end

# can be a bit easier if you can return
def complex_operation(...)
  result1 = FirstService.new(...).call
  return result1 unless result1.success?
  
  result 2 = SecondService.new(result1.payload[...], ...).call
  return result2 unless result2.success?
  
  ThirdService.new(result2.payload).call
end

With chaining, the same can be expressed in the following way:

result = FirstService.new(...).call
  .flat_map { |res| SecondService.new(res[...], ...).call }
  .flat_map { |res| ThirdService.new(res).call }

# or nicer, with support from services for `.call` and using the `_1` variable
result = FirstService.(...)
  .flat_map { SecondService.(_1[...], ...) }
  .flat_map { ThirdService.new(_1) }

Here is an example of another gem supporting this syntax: https://github.com/tomdalling/resonad#flat-mapping-aka-and_then

I'd like to know if you'd be open to supporting this behaviour before implementing this ๐Ÿ˜„

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.