You got Haskell in my Ruby! Cleaner Ruby validations using the Either monad and Kleisli gem
Introduction
Alternate title: “You could have invented Either!”
Update: It came to my attention from some Reddit comments that the simple example I use in this article is probably not the best for showing off a good use case of the kleisli gem as the “naive” code can be simplified. Therefore, I will be updating this post in the future with a more thorough example. In the meantime, I’d encourage you to read the article “Cleaner, safer Ruby API clients with Kleisli” by the kleisli gem author which has a more comprehensive use case!
I’m still a rank beginner at Haskell, but I guess it’s already leaving some tracks in my brain as I find myself wanting algebraic data types and pattern matching when I’m writing Ruby.
Algebraic Data Types Considered Harmful: once you use them, every language lacking them drives you to madness. #LangSec
— Nathan Wilcox (@least_nathan) March 27, 2015
Recently this became even more apparent when I had to perform a laundry list of validations against an image as part of a feature where users can upload custom avatars of themselves. Before I save the user-uploaded image to the database and do post-processing on it, I have to ensure that it meets some requirements:
- verify the file size is < 1MB
- verify the image is a valid image format
- verify the image is of type PNG, JPEG, or GIF
- verify the image dimensions are within 5,000x5,000 pixels
Also, I need a JSON-friendly version of the validation return value as the website is uploading the image via Ajax.
Version 1: naive validation object
Let’s write an object to do the validation as naively as possible. To fit into my Rails project neatly, I’m going to pass it the user-uploaded file directly (which is an ActionDispatch::Http::UploadedFile
).
Here’s a naive version (note I’m using mini_magick
but eliding the require
as this is a Rails project):
class AvatarValidator
MAX_SIZE = 1.megabyte
ALLOWED_FORMATS = %w(PNG JPEG GIF)
MAX_WIDTH, MAX_HEIGHT = [5000, 5000]
def initialize(uploaded_file)
@uploaded_file = uploaded_file
end
def validate
@valid ||= begin
if @uploaded_file.size > MAX_SIZE
"Picture size must be no larger than #{MAX_SIZE} bytes"
else
image = MiniMagick::Image.open(@uploaded_file.tempfile.path)
if image.valid?
if ALLOWED_FORMATS.include?(image.type)
if image.width > MAX_WIDTH or image.height > MAX_HEIGHT
"Picture dimensions must be within #{MAX_WIDTH}x#{MAX_HEIGHT}"
else
true
end
else
"Picture must be an image of type #{ALLOWED_FORMATS.join(' or ')}"
end
else
"Picture must be a valid image format"
end
end
rescue StandardError
"Sorry, an internal error occurred. Try again or contact us."
end
end
def validate_as_json
if validate == true
{success: true}
else
{success: false, message: validate}
end
end
end
The AvatarValidator
is initialized with the uploaded file. The validate
method will either return true
if the file is valid according to our specifications or a String
indicating the error message if not. The validate_as_json
wraps the validate
method, returning a Hash
that I can use in a controller for an Ajax-friendly .json
format.
There are a couple things I don’t like with this approach. The first being the return values: two primitive types are being returned with no context about what they represent. If the values stay pretty close to where they are generated, this is usually okay. However, when projects grow large, these kinds of values have a habit of ending up far away from where they are generated, like some kind of cancerous metastasis, causing confusion as to where they originate from when they cause bugs.
You especially see the problem happen with nil
s, because they can inhabit any type in Ruby and are used pretty often to represent invalid or unexpected values. So they can happily get passed between long chains of methods until one of the methods eventually tries to do something with it and blows up, far from where the nil
actually originated from, leaving you to have to dig through a stacktrace and figure out where it came from.
So the first thing I’m inclined to do is to create a type to add some context to what these return values represent. I’m thinking some kind of Success
class to represent a successful validation, and an Error
class to represent a failed validation. Additionally, the Error
class should also store some kind of message so that I know what went wrong during the validation.
Let’s give it a try:
Version 2: make return values first-class objects, wrapping a context
class AvatarValidator
MAX_SIZE = 1.megabyte
ALLOWED_FORMATS = %w(PNG JPEG GIF)
MAX_WIDTH, MAX_HEIGHT = [5000, 5000]
class Success; end
class Error
attr_reader :message
def initialize(message)
@message = message
end
end
def initialize(uploaded_file)
@uploaded_file = uploaded_file
end
def validate
@valid ||= begin
if @uploaded_file.size > MAX_SIZE
Error.new("Picture size must be no larger than #{MAX_SIZE} bytes")
else
image = MiniMagick::Image.open(@uploaded_file.tempfile.path)
if image.valid?
if ALLOWED_FORMATS.include?(image.type)
if image.width > MAX_WIDTH or image.height > MAX_HEIGHT
Error.new("Picture dimensions must be within #{MAX_WIDTH}x#{MAX_HEIGHT}")
else
Success.new
end
else
Error.new("Picture must be an image of type #{ALLOWED_FORMATS.join(' or ')}")
end
else
Error.new("Picture must be a valid image format")
end
end
rescue StandardError
Error.new("Sorry, an internal error occurred. Try again or contact us.")
end
end
def validate_as_json
case validate
when AvatarValidator::Success
{success: true}
when AvatarValidator::Error
{success: false, message: validate.message}
end
end
end
Now our return values can either be an AvatarValidator::Success
instead of a bare true
, or an AvatarValidator::Error
with a message
which contains the error instead of just a bare String
. The code is a little longer than before, but I feel like it’s safer now.
The other thing that is bothering me about this approach is the use of nested conditionals for each step of the validation. This approach is pretty much at its limit, as the successful validation is currently nested within conditional branches four layers deep! If I needed to add more validations, like ensuring the the image is of square proportions or doesn’t have any animation frames, I’d have to add even more nested layers.
One solution would be to convert the nested conditionals into a bunch of individual conditionals with early return
s, and Success
being at the very bottom, like so:
def validate
@valid ||= begin
if @uploaded_file.size > MAX_SIZE
return Error.new("Picture size must be no larger than #{MAX_SIZE} bytes")
end
unless ALLOWED_FORMATS.include?(image.type)
return Error.new("Picture must be an image of type #{ALLOWED_FORMATS.join(' or ')}")
end
# ...
Success.new
end
end
However that does not look like very idiomatic Ruby, and there is danger in forgetting to use return
in each conditional, or forgetting to put the Success
as the last value in the method (which would return one of those pesky nil
s if we ended on a conditional branch not-taken).
Luckily, there is a concept we can borrow from functional programming that will both clean up this conditional branching mess as well as generalize the concept that we invented about propagating the success/error context of our validation: it’s called the Either monad.
The general concept of the Either monad is that you wrap success values in a Right
, and failures in a Left
. The mnemonic to remember which is which is to remember that “right” = “correct.” If you’re aware of the connotation of “left” being “sinister” or “evil”, you could also remember that. (Fun fact: I was naturally left-handed as a small child until my grandfather would slap my hand every time I would try to use it dominantly. Thanks for beating the evil out of me Grandpa! :))
But in short, we will be replacing our use of AvatarValidator::Success
with Right
and AvatarValidator::Error
with Left
.1
I’m going to use the Kleisli gem to write this version of the validator. This gem helps clean up our conditional validation mess by introducing a >->
operator, which lets us do a sort of pattern-matching on these two possible Either
values. If given a Right
value, >->
will unwrap the value inside the Right
and evaluate a block with it. If given a Left
, it will simply ignore/skip the block. In this way, >->
lets us build a sort of short-circuiting pipeline for doing validation, which will either immediately return a Left
with an error message if any step in the pipeline returns a Left
, or a Right
only if every step in the pipeline returns a Right
.
Let’s try it out!
Version 3: Either monad using the Kleisli gem
require 'kleisli'
class AvatarValidator
MAX_SIZE = 1.megabyte
ALLOWED_FORMATS = %w(PNG JPEG GIF)
MAX_WIDTH, MAX_HEIGHT = [5000, 5000]
def initialize(uploaded_file)
@uploaded_file = uploaded_file
end
def validate
@valid ||= begin
Right(@uploaded_file) >-> value {
if value.size > MAX_SIZE
Left("Picture size must be no larger than #{MAX_SIZE} bytes")
else
Right(value)
end
} >-> value {
image = MiniMagick::Image.open(value.tempfile.path)
if image.valid?
Right(image)
else
Left("Picture must be a valid image format")
end
} >-> value {
if ALLOWED_FORMATS.include?(value.type)
Right(value)
else
Left("Picture must be an image of type #{ALLOWED_FORMATS.join(' or ')}")
end
} >-> value {
if value.width > MAX_WIDTH or value.height > MAX_HEIGHT
Left("Picture dimensions must be within #{MAX_WIDTH}x#{MAX_HEIGHT}")
else
Right(value)
end
}
rescue StandardError
Left("Sorry, an internal error occurred. Try again or contact us.")
end
end
def validate_as_json
case validate
when Kleisli::Either::Right
{success: true}
when Kleisli::Either::Left
{success: false, message: validate.value}
end
end
end
I hope you find this as readable as I do! I find it really easy to see at a glance what the different steps in the pipeline are doing, and the chaining using the >->
operator helps to prevent fat-finger mistakes.
I have to give props to the Kleisli gem for striking a nice balance between providing useful functional programming tools while still keeping the syntax Rubyish. Also, after looking through similar gems, I find it comforting that it “aims to be idiomatic Ruby to use in Enter-Prise production apps, not a proof of concept.” Some other similar gems seem to either have unwieldy syntax or are more toy academic exercises (which is fine, but I don’t want to use them for Serious Business™).
Bonus version: type-annotating methods with contracts.ruby
To add even more safety to this object, there is an interesting gem out there called contracts.ruby which basically lets you add runtime type checking to the boundaries of your methods. This can’t give us the purely functional static type checking that languages like Haskell have, but it is a nice tool for catching erroneous inputs and outputs early on, preventing bad values from propagating between method calls. It’s really great for catching dumb mistakes like returning a nil
from a method that shouldn’t be, and the DSL is pretty expressive (as you’ll see, I can even describe the internal structure of a Hash
that should be returned).
Here’s a version with contracts.ruby
type-checking added to the methods:
require 'kleisli'
require 'contracts'
class AvatarValidator
include Contracts
MAX_SIZE = 1.megabyte
ALLOWED_FORMATS = %w(PNG JPEG GIF)
MAX_WIDTH, MAX_HEIGHT = [5000, 5000]
Contract ActionDispatch::Http::UploadedFile => Any
def initialize(uploaded_file)
@uploaded_file = uploaded_file
end
Contract None => Kleisli::Either
def validate
@valid ||= begin
Right(@uploaded_file) >-> value {
if value.size > MAX_SIZE
Left("Picture size must be no larger than #{MAX_SIZE} bytes")
else
Right(value)
end
} >-> value {
image = MiniMagick::Image.open(value.tempfile.path)
if image.valid?
Right(image)
else
Left("Picture must be a valid image format")
end
} >-> value {
if ALLOWED_FORMATS.include?(value.type)
Right(value)
else
Left("Picture must be an image of type #{ALLOWED_FORMATS.join(' or ')}")
end
} >-> value {
if value.width > MAX_WIDTH or value.height > MAX_HEIGHT
Left("Picture dimensions must be within #{MAX_WIDTH}x#{MAX_HEIGHT}")
else
Right(value)
end
}
rescue StandardError
Left("Sorry, an internal error occurred. Try again or contact us.")
end
end
Contract None => Or[{ :success => Bool}, { :success => Bool, :message => String }]
def validate_as_json
case validate
when Kleisli::Either::Right
{success: true}
when Kleisli::Either::Left
{success: false, message: validate.value}
end
end
end
Now if we pass an unexpected value to any of these methods (or try to return an unexpected value from them), we will get an immediate runtime type error:
>> AvatarValidator.new('/tmp/path/to/some/file')
ContractError: Contract violation for argument 1 of 1:
Expected: ActionDispatch::Http::UploadedFile,
Actual: "/tmp/path/to/some/file"
Value guarded in: AvatarValidator::initialize
With Contract: ActionDispatch::Http::UploadedFile => Any
At: /home/abe/code/gun_crawler_web/app/models/avatar_validator.rb:12
Which is a big improvement over the bizarre errors you’d get when you try to use a value of the wrong type as if it were a different type.
If you enjoyed this, check out Haskell!
This whole example really uses only the most basic concepts in Haskell. If any of this has piqued your interest, I highly recommend learning some Haskell! The best resource for that in my opinion is Chris Allen (@bitemyapp)’s guide (it’s the one I find the best as a beginner myself, anyway).
And if you’re in the Madison, WI area, come check out the Haskell meetup hosted by Bendyworks! We’ve just started working through the exercises referenced in Chris Allen’s guide, so it’s a great time to come if you’re a beginner like me.
References
- “Cleaner, safer Ruby API clients with Kleisli”
- Kleisli gem on GitHub
- The contracts.ruby tutorial
- contracts.ruby on GitHub
- Learn Haskell using Chris Allen (@bitemyapp)’s guide
Footnotes
1 Note that unlike our AvatarValidator::Success
, a Right
actually wraps a value. This is required so that we can build the pipeline that unwraps the value and passes it to the next block.
Technically what we had actually built was sort of an upside-down Maybe monad around error values, with a None
(AvatarValidator::Success
) indicating success (no error), and a Just m
(AvatarValidator::Error message
) indicating the error + error message.