
Hashes are the most common data structures in Ruby and Rails apps. In this tutorial, I’ll describe a simple tip that makes working with hash values less prone to errors. It also improves code readability and provides a unified way of handling data structure issues.
That’s a lot of promises for a quick tip, so let’s get started!
How not to work with Ruby hashes…
Deeply nested hashes are first-class citizens in Rails apps, and it is a common practice to write code like that:
if params[:user][:comment][:body].present?
# some logic
else
# other logic
end
One disadvantage of this approach is that it implicitly assumes a hash structure. In this particular example, we’re working with params
so an external source of data. By writing code like that, you’re allowing your users to crash the app because you’re optimistically assuming that the received data structure will always be correct. Invalid input could raise different errors depending on the payload.
A dig
method introduced in Ruby 2.3 can offer a slight improvement:
begin
if params.dig(:user, :comment, :body).present?
# some logic
else
# other logic
end
rescue TypeError
# handle invalid structure
end
But dig
API makes it impossible to differentiate the missing Hash key from the present key containing a nil
value. And in practice, it’s often necessary to handle those two cases separately.
Hash fetch
to the rescue
A built-in Hash fetch
offers another solution to the described issue. Let’s see it in action:
begin
if params.fetch(:user).fetch(:comment).fetch(:body).present?
# some logic
else
# other logic
end
rescue KeyError
# handle missing key
end
For the price of a slightly more verbose implementation, we can now easily handle params
with missing keys. But we’re still making an implicit assumption that received data will contain nested hashes in the accessed keys. Users could still crash our app by sending the following params
:
{ user: { comment: nil } }
And the chaining looks kind of ugly. So let’s see how we can do it even better with a simple Hash extension.
Introducing deep_fetch
So here’s our final implementation using the custom deep_fetch
Hash method:
begin
if params.deep_fetch(:user, :comment, :body) { raise ParamsError, "Invalid input" }.present?
# some logic
else
# other logic
end
rescue ParamsError
# handle invalid params
end
deep_fetch
works like a combination of fetch
and dig
. Instead of returning nil
when a key is not found, it raises a KeyError
or returns a result of running a provided block. Here’s the monkey-patched implementation:
config/initializers/deep_fetch.rb
module DeepFetch
def deep_fetch(*keys, &block)
keys.reduce(self) do |hash_object, key|
hash_object.fetch(key)
end
rescue NoMethodError, KeyError, ActionController::ParameterMissing => e
if block_given?
block.call
else
raise KeyError, e.message, e.backtrace
end
end
end
class Hash
include DeepFetch
end
class ActionController::Parameters
include DeepFetch
end
ActionController::Parameters
does not inherit from Hash
, so it has to be extended separately.If you’re not fond of monkey-patching, you can use refinements instead:
module HashHelpers
refine Hash do
include DeepFetch
end
refine ActionController::Parameters do
include DeepFetch
end
end
and include HashHelpers
in every class where you want to use the extension:
class UsersController
using HashHelpers
end
By using deep_fetch
, we can handle all the described cases. If the structure is invalid, we’ll receive an easy to rescue error, so users can no longer break our app by sending invalid input. Even if the received value is nil
, we can be sure that it was extracted from correctly structured params.
Opinionated summary
deep_fetch
could be a viable alternative to heavyweight libraries for validating the structure of any Ruby Hash. I’d even suggest going as far as assuming Hash bracket notation as an explicit sign that missing key is expected and should be handled accordingly. It means that the following code should not pass a code review:
value = params[:comment][:body]
It implicitly assumes that comment
contains a Hash, so it is a highly probable source of random NoMethodError
bugs on production. Adapting a deep fetching approach also makes sense for internal hash values, i.e. those not received from users. I’ve used to fight over this one in code reviews vs. comments like:
“What’s the point of using deep_fetch
for the value that I AM SURE is there?”.
And that’s exactly the point! By using deep_fetch
, you’re making it explicit that value must be there, and it’s clear that invalid structure is not expected.
I think that sticking to the convention of always deep fetching provides many benefits with minimal complexity overhead. Implementation is as simple as dropping in a dozen lines of code into your project, so I highly encourage you to give it a try.