Using Hash#fetch
in Ruby for better nil handling
Pulling values out of a Hash in Ruby is simple with the []
method, but problems can occur when the value you’re looking up isn’t there. This can result in cumbersome nil checks, or our absolute favourite error Undefined method for nil:NilClass
.
Let’s look at an example of a classifieds site that sorts its listings when displaying them for the user, and some of the ways we can use Hash#fetch
to proactively handle those nils before they happen.
Just a quick note on the code—these examples are moderately contrived and not necessarily how you’d solve these problems in production, but hey, at least they illustrate my points!
Using Hash#fetch to set a default
def index
valid_sort_orders = {
lowest_price: :asc,
highest_price: :desc
}
sort_order = valid_sort_orders[listings_params[:sort]]
@listings = Listing.order(price: sort_order)
end
We have a problem here—what if the sort
parameter is not provided by the user, or they provide something that’s invalid? There’s many ways to handle this problem, for example, we could set a default with the ||
operator. This is a pretty common pattern, and a perfectly fine way to handle this case.
def index
valid_sort_orders = {
lowest_price: :asc,
highest_price: :desc
}
sort_order = valid_sort_orders[listings_params[:sort]] || :asc
@listings = Listing.order(price: sort_order)
end
Personally, I prefer to use the Hash#fetch
method for a slightly more elegant solution. With Hash#fetch
, if a key is not found in a hash, we can provide a default key to look for, which is quite a nice way to handle our nil situation.
def index
valid_sort_orders = {
lowest_price: :asc,
highest_price: :desc
}
sort_order = valid_sort_orders.fetch(listings_params[:sort], :asc)
@listings = Listing.order(price: sort_order)
end
Semantics alone aren’t a great reason to use this pattern though, so lets look at some more interesting examples where Hash#fetch
can be used to proactively handle nils.
Using Hash#fetch
to set a falsey value
This pattern is useful when you want to accept falsey values from a caller, but default to a truthy value. Let’s look at an example.
Here we want to be able to control with parameters whether to include a seller’s details in the response, with a default value of true
. It’s easy to accidentally do something like this:
def index
@include_seller_details = listings_params[:include_metadata] || true
valid_sort_orders = {
lowest_price: :asc,
highest_price: :desc
}
sort_order = valid_sort_orders.fetch(listings_params[:sort], :asc)
@listings = Listing.order(price: sort_order)
end
Which is not going to work correctly! Because if listing_params[:include_metadata]
is a falsey value, this will evaluate to true
anyway. It’s probably not disasterous, but it means that we’re going to be sending more information in the response than we want to, which is impolite at best and could be a security concern at worst.
One way to fix this would be to use Hash#fetch
def index
@include_seller_details = listing_params.fetch(:include_metadata, true)
valid_sort_orders = {
lowest_price: :asc,
highest_price: :desc
}
sort_order = valid_sort_orders.fetch(listings_params[:sort], :asc)
@listings = Listing.order(price: sort_order)
end
In this example, if there is any value at :include_metadata
, including a falsey value, it will be set, which is exactly what we want.
Run a block of code if you don’t find what you’re looking for
In the case where you don’t find what you’re looking for in a hash, returning a default value is nice, as we’ve seen. Sometimes, though, a simple value won’t do, and you might want to run a block of code as a fallback instead. Hash#fetch
accepts a block to help you achieve this, which is pretty nifty!
In this example, if a user sends an invalid sort order to the API, we will record that in an analytics service. That will help us decide if we want to build that feature for our users next. If a lot of users are requesting to order Listings by popular
or new
, it’s handy for us to know that. So, we’ll report the value, then return the default.
def index
@include_seller_details = listing_params.fetch(:include_metadata, true)
valid_sort_orders = {
lowest_price: :asc,
highest_price: :desc
}
sort_order = valid_sort_orders.fetch(listings_params[:sort]) do |value|
Analytics.report("user requested to sort listings by #{value}")
:asc
end
@listings = Listing.order(price: sort_order)
end
Safer handling of environment variables with Hash#fetch
Poor handling of environment variables can make for some pretty disasterous outcomes (…ask me how I know). After being bitten more than once, I like to use Hash#fetch
when retrieving environment variables.
This is especially important if you are using environment variables to feature flag releases, or if adding/removing environment variables is something that is handled separately to your normal code deploy process.
code expecting an environment variable + a silent failure if it’s not there + failing to correctly set an environment variable = potentially very costly mistake
🙃
def index
seasonal_discount = ENV["SEASONAL_DISCOUNT"]
valid_sort_orders = {
lowest_price: :asc,
highest_price: :desc
}
sort_order = valid_sort_orders.fetch(listings_params[:sort], :asc)
@listings = Listing.order(price: sort_order)
if seasonal_discount
@listings = @listings.map(&:apply_seasonal_discount)
end
end
In this case, if someone fails to set the SEASONAL_DISCOUNT
environment variable, we might fail to notice because nothing here is going to throw an error. Our customers will miss out on a good deal!
We can use Hash#fetch
so that this fails noisily when no env var is found.
def index
seasonal_discount = ENV.fetch("SEASONAL_DISCOUNT")
valid_sort_orders = {
lowest_price: :asc,
highest_price: :desc
}
sort_order = valid_sort_orders.fetch(listings_params[:sort], :asc)
@listings = Listing.order(price: sort_order)
if seasonal_discount
@listings = @listings.map(&:apply_seasonal_discount)
end
end
If no environment variable is found, this will fail noisily and your code probably won’t even deploy correctly. You could also choose to set a default if it makes more sense to do so, but there are often cases when a noisy failure is preferable.
Top comments (3)
I think it's worth mentioning that
Hash#[]
is a bit faster thanHash#fetch
so if you only want default value behavior it makes sense to usehash[:foo] || :bar
in some heavy load cases (more info is here)Learned this one from the "Confident Ruby" book (which I loved).
Since then I became a fan of
fetch
ing :DDid not know about this second parameter on
fetch
. Good tip!