As a Ruby developer, you are probably familiar with the #fetch method. In this article we will examine some of its behaviors.

What the fetch ?

Returns a value from the hash for the given key. If the key can’t be found, there are several options: With no other arguments, it will raise an KeyError exception; if default is given, then that will be returned; if the optional code block is specified, then that will be run and its result returned.

h = { a: 1, b: 2 }
h.fetch(:a) # => 1
h.fetch(:c) # => KeyError: key not found: :c
h.fetch(:c, 3) # => 3
h.fetch(:c) { 3 } # => 3

#fetch is also implemented in the Array class:

a = [1, 2, 3]
a.fetch(0) # => 1
a.fetch(42) # => IndexError: index 42 outside of array bounds: -3...3
a.fetch(42, 4) # => 4
a.fetch(42) { 4 } # => 4

#fetch is also implmented in ENV.

Why Use #fetch Over #[] ?

Let’s say we have a product represented by this hash:

product = { id: 123, infos: { name: 'Yellow Table', price: 150 } }

In order to get the product name’s length, we can do the following:

product[:infos][:name].length # => 12

If the name field is not present in our hash, an exception is raised:

product[:infos][:name].length
# => NoMethodError: undefined method `length' for nil:NilClass

This error message is not descriptive enough about which key is missing. It can be problematic when debuging a bigger codebase.

Let’s rewrite this statement using #fetch:

product.fetch(:infos).fetch(:name).length # => KeyError: key not found: :name

The raised exception is obviously more meaningful.

You can go a step further by raising a custom exception or displaying a custom error message by passing a block to #fetch:

product.fetch(:infos).fetch(:name) do |key|
  raise "Invalid product (missing #{key})"
end
# => RuntimeError: Invalid product (missing name)

#fetch and Default Values

You can specify a default value in case the key is missing. There are two ways of doing it:

{}.fetch(:missing, :default)
# => :default

{}.fetch(:missing) { :default }
# => :default

The main difference is that the second argument will always be evaluted whether the key is present or not. Whereas the block will be run only if the key is missing.

def default
  puts 'API request...'
  '+0-000-000-0000'
end

{ phone: '+0-000-000-0000' }.fetch(:phone, default)
# API request...
# => "+0-000-000-0000"

{ phone: '+0-000-000-0000' }.fetch(:phone) { default }
# => "+0-000-000-0000"

This subtle difference may lead to massive performance issues if the default value requires an expensive computation (e.g. API request).

Nested Hashes and Default Values

user = { id: 123, photo: { url: 'http://photo.png' } }
user.fetch(:photo).fetch(:url) # => "http://photo.png"

How can we set a default photo if the :photo hash is missing?

user = { id: 123 }
user.fetch(:photo).fetch(:url) # => KeyError: key not found: :infos

We can acomplish this in a simple way by using a hash as the default value:

user.fetch(:photo, {}).fetch(:url, 'http://default.png') # => "http://default.png"

Difference Between hash.fetch(:key, :default) and hash[:key] || :default

You may have already done something like:

a = hash[:key] || 'default'

In some cases it may lead to an unexpected behavior:

h = { foo: nil, bar: false }

h[:missing] || :default # => :default
h[:foo] || :default # => :default
h[:bar] || :default # => :default

h.fetch(:missing, :default) # => :default
h.fetch(:foo, :default) # => nil
h.fetch(:bar, :default) # => false

|| is checking if the value is nil, true, or false. || does not check for missing key.

In our case h[:missing] returns nil. But it’s not always the case.