DEV Community

Raquel
Raquel

Posted on

Using `Hash#fetch` in Ruby for better nil handling

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.

Those are some of my favourite uses for Hash#fetch. It's a really useful tool for any Rubyist, and I think it’s good to remember that [] is not the only way to retrieve values from a Hash.

Top comments (3)

Collapse
 
nashby profile image
Vasiliy Ermolovich

I think it's worth mentioning that Hash#[] is a bit faster than Hash#fetch so if you only want default value behavior it makes sense to use hash[:foo] || :bar in some heavy load cases (more info is here)

Collapse
 
lightalloy profile image
Anna Buianova

Learned this one from the "Confident Ruby" book (which I loved).
Since then I became a fan of fetching :D

Collapse
 
databasesponge profile image
MetaDave 🇪🇺

Did not know about this second parameter on fetch. Good tip!