Accessing Values from Nested Hashes

I need a function that will allow me to access values from a nested (i.e. multidimensional) hash. However, the shape of the hash is not strictly fixed.

If you knew the keypath already (i.e. you didn't need it to be a param that was passed into a function), the oldest standard way to achieve this in Ruby is:

hash[:path] && hash[:path][:to] && hash[:path][:to][:key]

If you wanted to take that approach and put it into a method, you could use Enumerable#reduce to work with the keypath's array:

def access(hash, keypath)
keypath.reduce(hash) { |memo, key| memo && memo[key] }
end

Starting in Ruby 2.3, the Hash class actually added a method that does essentially this. Hash#dig takes a keypath and will access the value:

hash.dig(:path, :to, :key)

So, we could rewrite our function to use Hash#dig like so:

def access(hash, keypath)
hash.dig(*keypath)
end

That is both clean and uses modern Ruby semantics; however, it is not without its limitations. Let's consider the following 2 hashes:

hash1 = {
path: {
to: {
key: 'value'
}
}
}
hash2 = {
path: {
to: 'key'
}
}

And let's also consider the following three keypaths:

keypath1 = %i[path to key]
keypath2 = %i[path to nested key]
keypath3 = %i[path to key then another]

What will happen in these six scenarios?

access(hash1, keypath1)
access(hash1, keypath2)
access(hash1, keypath3)
access(hash2, keypath1)
access(hash2, keypath2)
access(hash2, keypath3)

Well, here's the answer:

> access(hash1, keypath1)
=> "value"
> access(hash1, keypath2)
=> nil
> access(hash1, keypath3)
TypeError: String does not have #dig method
> access(hash2, keypath1)
TypeError: String does not have #dig method
> access(hash2, keypath2)
TypeError: String does not have #dig method
> access(hash2, keypath3)
TypeError: String does not have #dig method

Scenario 1 makes sense. The hash has those keys defined in that structure, so the value is accessed.

Scenario 2 also makes sense. The subhash returned from hash[:path][:to] does not have the key :nested, so a nil is returned.

But each of the other 4 scenarios throw this TypeError. First, let's answer why.

You can get this error simply. Call 'foo'.dig(:key). We recall that dig is an instance method on the Hash class. It is not an instance method on the String class. Thus, when we try to call that method on an instance of String, we get this error.

This error is being thrown in our final four scenarios because as soon as we hit a scalar value (a string in these cases), the implicit chained call to dig on that value throws the error. I say that it is the "implicit chained call to dig" because the Hash#dig method is implemented recursively.

So, the way Hash#dig works is that it will return a nil if it encounters a key that is not present in the current (sub-)hash that it is processing; however, if it encounters a key that is present, but that returns a scalar value, it will blow up.


We need a function that won't blow up. We need a function that either returns the value or returns nil.

Maybe our original implementation of access would work?

def access(hash, keypath)
keypath.reduce(hash) { |memo, key| memo && memo[key] }
end
> access(hash1, keypath1)
=> "value"
> access(hash1, keypath2)
=> nil
> access(hash1, keypath3)
TypeError: no implicit conversion of Symbol into Integer
> access(hash2, keypath1)
TypeError: no implicit conversion of Symbol into Integer
> access(hash2, keypath2)
TypeError: no implicit conversion of Symbol into Integer
> access(hash2, keypath3)
TypeError: no implicit conversion of Symbol into Integer

Not quite (I leave the explanation of this error to the reader).

Using Hash#fetch instead of Hash#dig gives us a similar problem:

def access(hash, keypath)
keypath.reduce(hash) { |memo, key| memo.fetch(key, {}) }
end
> access(hash1, keypath1)
=> "value"
> access(hash1, keypath2)
=> {}
> access(hash1, keypath3)
NoMethodError: undefined method `fetch' for "value":String
> access(hash2, keypath1)
NoMethodError: undefined method `fetch' for "key":String
> access(hash2, keypath2)
NoMethodError: undefined method `fetch' for "key":String
> access(hash2, keypath3)
NoMethodError: undefined method `fetch' for "key":String

Clearly, what we need is a way to tentatively call the method; and ActiveSupport's Object#try fits the bill nicely. So, let's try pairing Object#try with Hash#dig:

def access(hash, keypath)
keypath.reduce(hash) { |memo, key| memo.try(:dig, key) }
end
> access(hash1, keypath1)
=> "value"
> access(hash1, keypath2)
=> nil
> access(hash1, keypath3)
=> nil
> access(hash2, keypath1)
=> nil
> access(hash2, keypath2)
=> nil
> access(hash2, keypath3)
=> nil

Success!

When you need to attempt to access a value from a nested/multidimensional hash given a keypath that may or may not match the shape of the hash, try and dig.