Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
helps dealing with exceptional situations, it comes from the sphere of functional programming and bringing the goodies I have come to love in Scala to my ruby projects
Ruby

Fetching latest commit…

Cannot retrieve the latest commit at this time

Failed to load latest commit information.
lib
spec
.gitignore
.rspec
.travis.yml
CHANGELOG.md
Gemfile
Guardfile
LICENSE
README.md
Rakefile
monadic.gemspec

README.md

Monadic

helps dealing with exceptional situations, it comes from the sphere of functional programming and bringing the goodies I have come to love in Scala and Haskell to my ruby projects.

My motivation to create this gem was that I often work with nested Hashes and need to reach deeply inside of them so my code is sprinkled with things like some_hash.fetch(:one, {}).fetch(:two, {}).fetch(:three, "unknown").

We have the following monadics (monads, functors, applicatives and variations):

  • Maybe - use if you have one exception
  • Either - use if you have many exceptions, and one call depends on the previous
  • Validation - use if you have many independent calls (usually to validate an object)

What's the point of using monads in ruby? To me it started with having a safe way to deal with nil objects and other exceptions. Thus you contain the erroneous behaviour within a monad - an indivisible, impenetrable unit. Functional programming considers throwing exceptions to be a side-effect, instead we propagate exceptions, i.e. return them as a result of a function call.

A monad is most effectively described as a computation that eventually returns a value. -- Wolfgang De Meuter

Usage

Option

Is an optional type, which helps to handle error conditions gracefully. The one thing to remember about option is: 'What goes into the Option, stays in the Option'.

Option(User.find(123)).name._         # ._ is a shortcut for .fetch 

# if you prefer the alias Maybe instead of option
Maybe(User.find(123)).name._

# confidently diving into nested hashes
Maybe({})[:a][:b][:c]                   == None
Maybe({})[:a][:b][:c].fetch('unknown')  == None
Maybe(a: 1)[:a]._                       == 1

Basic usage examples:

# handling nil (None serves as NullObject)
obj = nil
Option(obj).a.b.c            == None

# None stays None
Option(nil)._                == "None"
"#{Option(nil)}"             == "None"
Option(nil)._("unknown")     == "unknown"
Option(nil).empty?           == true
Option(nil).truly?           == false

# Some stays Some, unless you unbox it
Option('FOO').downcase       == Some('foo') 
Option('FOO').downcase.fetch == "foo"
Option('FOO').downcase._     == "foo"
Option('foo').empty?         == false
Option('foo').truly?         == true

Map, select:

Option(123).map   { |value| User.find(value) } == Option(someUser)    # if user found
Option(0).map     { |value| User.find(value) } == None                # if user not found
Option([1,2]).map { |value| value.to_s }       == Option(["1", "2"])  # for all Enumerables

Option('foo').select { |value| value.start_with?('f') } == Some('foo')
Option('bar').select { |value| value.start_with?('f') } == None

Treat it like an array:

Option(123).to_a         == [123]
Option([123, 456]).to_a  == [123, 456]
Option(nil)              == []

Falsey values (kind-of) examples:

user = Option(User.find(123))
user.name._

user.subscribed?              # always true
user.subscribed?.truly?       # true if subscribed is true
user.subscribed?.fetch(false) # same as above
user.subscribed?.or(false)    # same as above

Remember! an Option is never false (in Ruby terms), if you want to know if it is false, call #empty? of #truly?

#truly? will return true or false, always.

Slug example

# instead of 
def slug(title)
  if title
    title.strip.downcase.tr_s('^[a-z0-9]', '-')
  end
end

# or 

def slug(title)
  title && title.strip.downcase.tr_s('^[a-z0-9]', '-')
end

# do it with a default
def slug(title)
  Option(title).strip.downcase.tr_s('^[a-z0-9]', '-')._('unknown-title')
end

Either

Its main purpose here to handle errors gracefully, by chaining multiple calls in a functional way and stop evaluating them as soon as the first fails. Assume you need several calls to construct some object in order to be useful, after each you need to check for success. Also you want to catch exceptions and not let them bubble upwards.
What is specific to this implementation is that exceptions are caught within the execution blocks. This way I have all error conditions wrapped in one place.

Success represents a successfull execution of an operation (Right in Scala, Haskell).
Failure represents a failure to execute an operation (Left in Scala, Haskell).

The Either() wrapper will treat all falsey values nil, false or empty? as a Failure and all others as Success. If that does not suit you, use Success or Failure only.

result = parse_and_validate_params(params).                 # must return a Success or Failure inside
            bind ->(user_id) { User.find(user_id) }.        # if #find returns null it will become a Failure
            bind ->(user)    { authorized?(user); user }.   # if authorized? raises an Exception, it will be a Failure 
            bind ->(user)    { UserDecorator(user) }

if result.success?
  @user = result.fetch                                      # result.fetch or result._ contains the 
  render 'page'
else
  @error = result.fetch
  render 'error_page'
end

You can use alternate syntaxes to achieve the same goal:

# block and Haskell like >= operator
Either(operation).
  >= { successful_method }.
  >= { failful_operation }

# start with a Success, for instance a parameter
Success('pzol').
  bind ->(previous) { good }.
  bind ->           { bad  }

Either.chain do
  bind ->                   { good   }                     # >= is not supported for Either.chain, only bind
  bind ->                   { better }                     # better returns Success(some_int)
  bind ->(previous_result)  { previous_result + 1 }
end

either = Either(something)
either += truth? Success('truth, only the truth') : Failure('lies, damn lies')

Exceptions are wrapped into a Failure:

Either(true).
  bind -> { fail 'get me out of here' }                    # return a Failure(RuntimeError)

Another example:

Success(params).
  bind ->(params)   { Either(params.fetch(:path)) }        # fails if params does not contain :path
  bind ->(path)     { load_stuff(params)          }        # 

Storing intermediate results in instance variables is possible, although it is not very elegant:

result = Either.chain do
  bind { @map = { one: 1, two: 2 } }
  bind { @map.fetch(:one) }
  bind { |p| Success(p + 100) }
end

result == Success(101)

Validation

The Validation applicative functor, takes a list of checks within a block. Each check must return either Success of Failure.
If Successful, it will return Success, if not a Failure monad, containing a list of failures.
Within the Failure() provide the reason why the check failed.

Example:

def validate(person)
  check_age = ->(age_expr) {
    age = age_expr.to_i
    case 
    when age <=  0; Failure('Age must be > 0')
    when age > 130; Failure('Age must be < 130')
    else Success(age)
    end
  }

  check_sobriety = ->(sobriety) {
    case sobriety
    when :sober, :tipsy; Success(sobriety)
    when :drunk        ; Failure('No drunks allowed')
    else Failure("Sobriety state '#{sobriety}' is not allowed")
    end 
  }

  check_gender = ->(gender) {
    gender == :male || gender == :female ? Success(gender) : Failure("Invalid gender #{gender}")
  }

  Validation() do
    check { check_age.(person.age);          }
    check { check_sobriety.(person.sobriety) }
    check { check_gender.(person.gender)     }
  end
end

References

Installation

Add this line to your application's Gemfile:

gem 'monadic'

And then execute:

$ bundle

Or install it yourself as:

$ gem install monadic

Compatibility

Monadic is tested under ruby MRI 1.9.2, 1.9.3, jruby 1.9 mode, rbx 1.9 mode.

See the build status Build Status

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Added some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request
Something went wrong with that request. Please try again.