Yopt
A Scala inspired gem that introduces Options to Ruby while aiming for an idiomatic API.
Installation
Add this line to your application's Gemfile:
gem 'yopt'And then execute:
$ bundle
Or install it yourself as:
$ gem install yopt
Basic usage
The Option type models the possible absence of a value. It lets us deal with the uncertainty related to such a value being there without having to resort to errors or conditional blocks.
Instances of Option are either an instance of Yopt::Some - meaning the option contains a value - or the object Yopt::None - meaning the option is empty.
require 'yopt'
some = Yopt::Some.new(42)
none = Yopt::NoneWe can access and manipulate the optional value by passing a block to Option#map.
some.map {|value| value + 2} # returns Some(44)
none.map {|value| value + 2} # returns NoneWhen we are not interested in the result of a computation on the optional value, it is a good practice to use Option#each rather than Option#map. That will make our intention clearer.
some.each {|value| puts value} # prints 42 and returns `some`
none.each {|value| puts value} # does not print anything and returns `none`Notice that #each returns the calling option thus supporting method chaining.
We can safely retrieve the optional value by passing a default value to Option#get_or_else
some.get_or_else {0} # returns 42
none.get_or_else {0} # returns 0Notice how we are passing a block rather than an argument. This makes the evaluation of the default value lazy. In other words the block will only be evaluated if the caller in None.
This gives us the possibility to react in a special way to a None value without breaking the API fluency, e.g.
opt.each {|v| function_with_side_effects(v)}.get_or_else {log_failure}We can also filter the optional value depending on how it evaluates against a block via Option#select
some.select {|value| value < 0} # returns None
none.select {|value| value < 0} # returns None
some.select {|value| value > 0} # returns Some(42)We can easily turn any object into an Option by means of Option.call - aliased to Option[] for convenience.
For instance, this is useful when dealing with functions that might return nil to express the absence of a result.
Yopt::Option[nil] # returns None
Yopt::Option[42] # returns Some(42)A combination of the few methods just introduced already allows us to implement some pretty interesting logic. Checkout basics.rb in the docs folder to get some inspiration.
Why opt?
Using Options reduces the amount of branching in our code and lets us deal with exceptional cases in a seamless way. No more check-for-nil, no more rescue blocks, just plain and simple data transformation.
It also makes our code safer by treating the absence of something like a fully fledged object, and enables us to use the Null Object Pattern everywhere we want without the overhead of having to write specialized Null-type classes for different classes.
Advanced Usage
#reduce
Given an Option opt, a value c and a lambda f,
opt.reduce(c, &f)
returns c if opt is None, and f.(c, opt) otherwise.
This is a shortcut to
opt.map{|v| f.(c,v)}.get_or_else {c}`
#flatten and #flat_map
When working with functions returning Option, we might end up dealing with nested options...
maybe_sqrt = lambda {|x| Yopt::Option[x >= 0 ? Math.sqrt(x) : nil]}
maybe_increment = lambda {|x| Yopt::Option[x > 1 ? x + 1 : nil]}
maybe_sqrt.(4).map {|v| maybe_increment.(v)} # Some(Some(3.0))
maybe_sqrt.(1).map {|v| maybe_increment.(v)} # Some(None)Usually, this is not what we want, so we call Option#flatten on the result
maybe_sqrt.(4).map {|v| maybe_increment.(v)}.flatten # Some(3.0)
maybe_sqrt.(1).map {|v| maybe_increment.(v)}.flatten # NoneOption#flat_map combines the two calls into one
maybe_sqrt.(4).flat_map {|v| maybe_increment.(v)} # Some(3.0)
maybe_sqrt.(1).flat_map {|v| maybe_increment.(v)} # NoneA difference to keep in mind is that #flatten will raise an error if the wrapped value does not respond to #to_ary
Yopt.Option[42].flatten # raises TypeError: Argument must be an array-like object. Found Fixnumwhereas #flat_map behaves like #map when the passed block does not return an array-like value
Yopt.Option[42].flat_map{|v| v} # returns Some(42)#zip
When dealing with a set of Option instances, we might want to ensure that they are all defined - i.e. not empty - before continuing a computation...
email_opt.each(&send_pass_recovery) unless (email_opt.empty? or captcha_opt.empty?)We can avoid empty? checks by using Option#zip
email_opt.zip(captcha_opt).each{|(email,_)| send_pass_recovery(email)}Option#zip returns None if any of the arguments is None or if the caller is None
Yopt::None.zip Option[42] # None
Option[42].zip Yopt::None # None
Option[42].zip Option[0], Yopt::None, Option[-1] # NoneWhen both the caller and all the arguments are defined then zip collects all the values in an Array wrapped in a Yopt::Some
Option[42].zip Option[0], Option["str"] # Some([42, 0, "str"])#or_else
When writing an application whose functionality depends on the availability of other services/information, it's a good idea to prepare for the scenario where such services/information are not available.
#or_else lets us define a chain of block calls that will stop as soon as one of them returns a non-empty option.
get_postcode = -> email do
get_from_cache[email]
.or_else { get_from_db[email] }
.or_else { get_from_remote[email] }
end#grep
We often find ourselves filtering data before applying a transformation...
opt.select {|v| (1...10).include? v}.map {|v| v + 1}In this scenario, Option#grep can sometimes make the code more concise
opt.grep(1...10) {|v| v + 1}Option#grep supports lambdas as well
is_positive = lambda {|x| x > 0}
opt.grep(is_positive) {|v| Math.log(v)}
# is equivalent to
opt.select(&is_positive).map {|v| Math.log(v)}Haskell Data.Maybe cheat sheet
Some (None?) might enjoy a comparison with Haskell's Maybe. Here is how the Data.Maybe API translate to Yopt.
maybe default f opt -> opt.map(&f).get_or_else {default}
isJust opt -> not opt.empty?
isNothing opt -> opt.empty?
fromJust opt -> opt.get
fromMaybe default opt -> opt.get_or_else {default}
listToMaybe list -> Option.ary_to_type list
maybeToList opt -> opt.to_a
catMaybes listOfOptions -> listOfOptions.flatten
mapMaybe f list -> list.flat_map &fDevelopment
After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run 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. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/lbarasti/yopt. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.