In an earlier post I discussed some potential issues with accessing values from a nested hash. That post ended with a method that utilized the ActiveSupport
try
in conjunction with the Ruby dig
method to allow for accessing a nested hash without throwing an error even if the keypath didn’t properly match the hash structure. Since writing that post, I have come to further refine that method.
Before jumping into any implementation details, I want to lay out my needs and write up some test cases to encode those needs.
The heart of the problem is that I often find myself attempting to access values from nested hashes that can take a wide variety of shapes; that is, they can be robustly hydrated or empty or any number of other states between. The first possible state we want to test for is dealing with an empty hash:
expect(safe_dig({}, %i[path to key])).to eq nilexpect(safe_dig({}, %i[path to key], 'default')).to eq 'default'
When attempting to access a value at a keypath for an empty hash, we want the default return value to be nil
, but we also want to be able to set our own default return value.
Next, let’s test accessing a scalar value from a nested hash:
expect(safe_dig({ path: { to: { key: 'value' } } }, %i[path to key])).to eq 'value'expect(safe_dig({ path: { to: { key: 'value' } } }, %i[path to key], 'default')).to eq 'value'
Next, we can test accessing a sub-hash from the nested hash:
expect(safe_dig({ path: { to: { key: 'value' } } }, %i[path to])).to eq { key: 'value' }expect(safe_dig({ path: { to: { key: 'value' } } }, %i[path to], 'default')).to eq { key: 'value' }
Now, let’s start testing the case when we try to access an unexistent path in the nested hash:
expect(safe_dig({ path: { to: { key: 'value' } } }, %i[path to another key])).to eq nilexpect(safe_dig({ path: { to: { key: 'value' } } }, %i[path to another key], 'default')).to eq 'default'
Aside from setting a default return value, these tests match precisely how Hash#dig
operates. Now, however, let’s start testing the case when we try to over-access the nested hash:
expect(safe_dig({ path: { to: { key: 'value' } } }, %i[path to key new])).to eq nilexpect(safe_dig({ path: { to: { key: 'value' } } }, %i[path to key new], 'default')).to eq 'default'
Were we to use simply Hash#dig
, this keypath would throw a TypeError: String does not have #dig method
because we would be trying to call #dig
on the 'value'
string. Our safe_dig
method, however, will simply return the default value (which is nil
by default).
The final bit of functionality we want to cover is working with either String
or Symbol
keys. One of the most frustrating parts of working with hashes in Ruby is trying to access a value at a keypath where the keys are strings but you provide a keypath of symbols (or vice versa). Let’s ensure our safe_dig
method works regardless of whether the keys or the keypath are symbols or strings:
expect(safe_dig({ path: { to: { key: 'value' } } }, %w[path to key])).to eq 'value'expect(safe_dig({ path: { to: { key: 'value' } } }, %w[path to key], 'default')).to eq 'value'expect(safe_dig({ path: { to: { key: 'value' } } }, %w[path to key new])).to eq nilexpect(safe_dig({ path: { to: { key: 'value' } } }, %w[path to key new], 'default')).to eq 'default' expect(safe_dig({ 'path' => { 'to' => { 'key' => 'value' } } }, %i[path to key])).to eq 'value'expect(safe_dig({ 'path' => { 'to' => { 'key' => 'value' } } }, %i[path to key], 'default')).to eq 'value'expect(safe_dig({ 'path' => { 'to' => { 'key' => 'value' } } }, %i[path to key new])).to eq nilexpect(safe_dig({ 'path' => { 'to' => { 'key' => 'value' } } }, %i[path to key new], 'default')).to eq 'default'
With these tests in place, let’s write our safe_dig
method. However, instead of using the method defined in the previous post (which relies on try
), let’s write a method that works with pure Ruby. Since we are accessing a value via a keypath, I find that Enumerable#reduce
makes the most sense to form the backbone of our method:
def safe_dig(hash, keypath, default = nil) keypath.reduce(hash) { |accessible, key| accessible[key] }end
Now, this method as written now will fail a number of our tests. We need to make the accessing of the key
safer. We can do so firstly by guarding against the case where the accessible
hash doesn’t have the key we desire to access:
def safe_dig(hash, keypath, default = nil) keypath.reduce(hash) do |accessible, key| return default unless accessible.key? key accessible[key] endend
This makes our method much safer, but we still have an issue if/when accessible
is some scalar value and not a hash; for, in that case, the value will not respond to the key?
method and thus our method will throw an error. To guard against that scenario generally, let’s add an initial guard clause that ensures the accessible
value is indeed a Hash:
def safe_dig(hash, keypath, default = nil) keypath.reduce(hash) do |accessible, key| return default unless accessible.is_a? Hash return default unless accessible.key? key accessible[key] endend
Now the method as it stands now will pass most of our tests; all of the tests, in fact, except for the tests dealing with strings and symbols. To make this method fully “safe”, we need to normalize our hash
and our keypath
to ensure the method works as desired regardless of whether keys in the hash
or values in the keypath
are strings or symbols. To that end, let’s normalize the hash
and the keypath
both to strings. The keypath
will be easy, as it is an Enumerable
, so we can simply use keypath.map(&:to_s)
. Deeply stringifying the hash
is a bit more difficult. As stated above, I don’t want to rely on ActiveSupport
, so we don’t have access to Hash#deep_stringify_keys
. Luckily for us, however, Ruby offers a relatively simple “hack” to achieve our result. We can use the JSON
module to deeply stringify our hash by simply calling JSON.parse
. Let’s bring these bits into our method:
def safe_dig(hash, keypath, default = nil) stringified_hash = JSON.parse(hash.to_json) stringified_keypath = keypath.map(&:to_s) stringified_keypath.reduce(stringified_hash) do |accessible, key| return default unless accessible.is_a? Hash return default unless accessible.key? key accessible[key] endend
And just like that we have a safe_dig
method that will allow to access values from (nested) hashes safely and intuitively!