The Tragedy of Maybe and Ruby
"How is this different from nil?" is the inevitable question I get from rubyists upon learning about the Maybe monad. Until a flash of inspiration the other day, I didn't quite have a good explanation for this question. Sure, I had an explanation, but I don't think it was very convincing to rubyists. It went something like: "Because it forces you to explicitly handle the nil case rather than accidentally let it through."
I've got a better explanation now, but it's not very heartening for rubyists. Let's start with examining why, in a general sense, Maybe
is a good thing. Then, we'll get to Maybe's unrequited love for Ruby.
Act I: To Test or To Compile
Let's begin by contrasting strongly-type-checked languages—like Haskell and Elm—with dynamically typed languages—like Ruby and Elixir. While each of these approaches (let's call them "checked" and "dynamic," respectively) have their own advantages and disadvantages, we're going to focus on a small subset: the types/classes of function arguments and function return values. When making big decisions, you'll want to look at the whole picture.
These two paradigms each have their own primary mechanism to inspire confidence in how a codebase's units interact. Dynamic languages tend to use Integration Testing, while Checked languages use Type-Checking during compilation. Tests at the unit- or functional-level ought to be identical between the paradigms, and we won't examine those here.
What I've found in the dynamic community is that Integration Tests are usually omitted in favor of Functional Tests. That is, instead of testing that Function A sends the right data to Function B, BDD adherents tend to write the top-level Functional Test of "Pressing this UI button should do that" or "Hitting that API endpoint does this", then fill in the cracks with unit tests. It's hardly surprising that Integration Tests are often skipped, given the sheer number of permutations of how functions are called within a codebase.
Checking types during compilation means that there is effectively a gatekeeper for every function in your codebase, asserting that the shape of the input will always be the same. Additionally, it ensures that you always return the same shape of data from each function. The best part? This is all automated; you don't have to write any tests to get this. Sure, you may have to write type annotations, but type annotations are basically comments that cannot lie (which is the best kind of comment).
Act II: A nil By Any Other Name
With a compiler automating away integration testing for data shape, we get an interesting and liberating side effect: normal types—like Strings or Arrays—can never potentially be nil. If your function accepts a String, you know it will always receive a String. If a 3rd-party function returns an Array, you know it cannot return nil (though it might be an empty array!).
Sure, there may be situations when you need to consider the possibility of a nil-like return value. For example, calling first
on an Array in Ruby will give you nil
. In a checked language, however, calling first
on an Array of Strings, whether empty or not, will return something that looks a bit like Maybe(String)
. Aha! Our first Maybe!
So in a type-checked language, calling reverse
on an Array
of Strings will return another Array
of Strings. And calling first
on an Array
of Strings will return a Maybe(String)
. The former is immediately usable, while the second screams out: "You can't use me until you split out the functionality of the happy path and the error path!"
That is, nearly everywhere in your code, you can use regular String
s and know that they cannot be nil, while saving Maybe(String)
for situations that really need it.
Act III: Ruby's Tragedy
But a darkness covers Ruby-land: we rubyists don't have a type-checker or a compiler. Therefore, we can never have confidence that a value that's being passed around is a String
or an Int
or a nil
. And if we add a Maybe
library, then we simply double the number of possible types that a value can be, adding Maybe(String)
, Maybe(Int)
, etc.
What rubyists would need is an exhaustive type checker. There are "optional" or "gradual" type-checkers out there for Ruby (c.f. Contracts and rdl), but the non-exhaustive aspect of these approaches do not inspire the confidence needed to walk into a codebase 12 months later, make a small change, and shunt off to CI for deployment. Additionally, these libraries (probably?) add overhead on every annotated function call, which should be just about every function call in your app.
If we wanted to truly solve the problem of codebase-exhaustiveness, however, I posit that it is impossible to create a fully robust type checker for Ruby. If we added some constraints to the language or its use—like eliminating instance_eval
, class_eval
, etc—I'd imagine an AST-based analysis could do the job. I suspect such a checker does not yet exist.
Unless such a checker exists, the effective use of Maybe
requires constant vigilance by an entire development team, since Ruby's default behavior allows nil-able values to sneak in anywhere. I think we can agree that "automated" is significantly better than "requires constant vigilance."
Epilogue
In essence, a rubyist's view on Maybe is constrained by the type-looseness of Ruby itself. When one assumes their code will run atop the Ruby VM, this is a pragmatic and eminently reasonable approach. When, however, one considers a type system unencumbered by the Ruby VM, Maybe
makes a whole lot more sense. And therein lies the tragedy of Maybe in Ruby.
UPDATED: Changed example to *_eval
instead of define_function
, courtesy of inspiration from azrazalea on lobste.rs.