After discovering the usefulness of the Hash#fetch method while watching an episode of Avdi Grimm’s excellent Ruby Tapas screencast, I’ve started using it more frequently. In this post I’ll share what I’ve learnt.

The Hash#fetch method returns the value stored for a given key, just like the Hash#[] method does. The difference between them lies in how they handle the case where the given key isn’t found in the hash.

While the Hash#[] method returns the hash’s default value when a key isn’t found, the Hash#fetch method will raise a KeyError. As harsh as this might seem, it can be very helpful when something you expect to be there isn’t.

For example, imagine you’re fetching data from a remote weather API. When parsed into a hash the data looks something like this:

response = {
  temp: -3.72,
  humidity: 68,
  weather: {
    id: 80,
    description: "broken clouds"
  }
}

Your application might use this data to construct a Weather object like so:

weather = Weather.new

weather.temperature = response[:temp]
weather.description = response[:weather][:description]

Now, let’s say that the API responses are sometimes missing the weather description property. This wouldn’t break the code above but in another part of your application, where the weather information is logged, an exception is raised.

logger.info "It is #{weather.temperature}°C and "\
            "#{weather.description.capitalize} in Stockholm"
#=> NoMethodError: undefined method `capitalize' for nil:NilClass

The code above assumes that the weather object has a description and it’s not clear from the error message why this weather object doesn’t.

If the code responsible for constructing the weather object had been using Hash#fetch, the problem would’ve been detected where it first arose:

weather.description = response.fetch(:weather).fetch(:description)
#=> KeyError: key not found: :description

It’s clear from the error message that the response is missing the weather description property.

This doesn’t solve the problem but it turns out we can use Hash#fetch for that too. When passed a block and a missing key, instead of raising a KeyError, Hash#fetch will call the block and return whatever it returns:

weather.description = response.fetch(:weather).fetch(:description) { "unknown" }

weather.description #=> "unknown"

This behavior is similar to how the Hash#[] method can be used together with the || operator but it isn’t quite the same:

options = {
  false: false,
  nil: nil
}

options[:missing] || "default"           #=> "default"
options[:false]   || "default"           #=> "default"
options[:nil]     || "default"           #=> "default"

options.fetch(:missing) { "default" }    #=> "default"
options.fetch(:false)   { "default" }    #=> false
options.fetch(:nil)     { "default" }    #=> nil

Hash#fetch only returns the default value if the key is missing, not when the corresponding value is falsey. That might not seem very different but it has some interesting consequences. For example, it enables you to write code like this:

def inform(name, message, options = {})
  greeting = options.fetch(:greeting) { "Hello" }

  print "#{greeting} " if greeting
  puts "#{name}, #{message}"
end

inform("Calle", "your pancakes are ready!")
#=> Hello Calle, your pancakes are ready!

inform("Calle", "your pancakes are ready!", greeting: "Howdy")
#=> Howdy Calle, your pancakes are ready!

inform("Calle", "your pancakes are ready!", greeting: false)
#=> Calle, your pancakes are ready!

Another interesting feature of Hash#fetch is that when a key is missing and it calls the given block, it will pass the missing key to the block.

{}.fetch(:missing) { |key| "The key '#{key}' is missing!" }
#=> "The key 'missing' is missing!"

This feature combined with the fact that lambdas and procs can be converted to blocks using the & operator makes it possible to reuse the default behavior for multiple calls to Hash#fetch:

cache = { name: "Calle" }

fallback = ->(key) { cache[key] = SlowStorage.get(key) }

name  = cache.fetch(:name, &fallback)
email = cache.fetch(:email, &fallback)
phone = cache.fetch(:phone, &fallback)