In Progress
Unit 1, Lesson 1
In Progress

Partial Safe Navigation

Video transcript & code

Here's a line of code I wrote the other day.

published_at = rss_info&.fetch(:published_at) || Time.now

It gathers a publishing timestamp from some RSS info, and substitutes the current time if nothing is found.

This code uses a few different techniques we've covered in the past, like new "safe navigation" operator

…and the fetch method as an assertion, from episode #008.

What might be a little surprising about this line of code is that it uses both of these methods together. Let's step through what this means.

To do this, we'll add some sample data to work with. We just need a hash containing a published_at key.

Then we'll check our result.

rss_info = {
  published_at: Time.new(2016, 01, 01)
}

published_at = rss_info&.fetch(:published_at) || Time.now
published_at                    # => 2016-01-01 00:00:00 -0500

The value we get is the timestamp we included in the sample data.

Now lets say we don't have any rss_info.

# rss_info = {
#   published_at: Time.new(2016, 01, 01)
# }
rss_info = nil

published_at = rss_info&.fetch(:published_at) || Time.now
published_at                    # => 2016-06-08 17:05:25 -0400

Instead of a failure, because of the safe navigation operator we get the fallback value of the current time.

Now let's return to what makes this code a little bit surprising.

We can think of a snippet of code like this one as a data path, similar to a path in a filesystem or as an XPath statement applied to an XML document. For instance, here's a data path that leads from a shoping cart to a customer's address, with intermediate steps for user and profile objects.

address = cart.user.profile.address

If we say that a cart might be missing a user…

address = cart&.user.profile.address

…it seems like a logical next step to make the following steps in the chain optional as well.

After all, if the user is missing then there will be no .profile.

address = cart&.user&.profile.address

And if the profile is missing, there can be no address.

address = cart&.user&.profile&.address

We can think of a line like this as "total safe navigation". Any link in the chain can be missing, and the result will be nil rather than an error.

By comparison, our timestamp-fetching line of code does not give us total safe navigation.

published_at = rss_info&.fetch(:published_at) || Time.now

Quite the opposite, in fact. It uses #fetch without a default value. Just as a quick refresher, if we send #fetch without a default value to a hash that doesn't contain the key, we get an exception.

{foo: 23}.fetch(:bar) # ~> KeyError: key not found: :bar
# =>

# ~> KeyError
# ~> key not found: :bar
# ~>
# ~> xmptmp-in6215THS.rb:1:in `fetch'
# ~> xmptmp-in6215THS.rb:1:in `<main>'

So why might we want to mix safe navigation with strict assertions about keys?

Well, let's consider the opposite case. Here's the idiomatic, fully-safe version of this data path.

Because we're dealing with hash key access, it uses an && operator rather than safe navigation.

published_at = (rss_info && rss_info[:published_at]) || Time.now

Now consider this scenario. We have some rss_info. But… uh oh. It was loaded from a cache file with a JSON parser, and the keys are strings, not symbols.

We now have a bug in our code. But are we informed of the bug? Nope. We just get the fallback Time.now value.

rss_info = {
  "published_at" => Time.new(2016, 01, 01)
}

published_at = (rss_info && rss_info[:published_at]) || Time.now
published_at                    # => 2016-06-08 17:55:06 -0400

Now let's switch the code back to doing partial safe navigation.

When we evaluate the code this time, we get a noisy error, letting us know that our assumption about the format of the data was wrong.

rss_info = {
  "published_at" => Time.new(2016, 01, 01)
}

published_at = rss_info&.fetch(:published_at) || Time.now # ~> KeyError: key not found: :published_at
published_at                    # =>

# ~> KeyError
# ~> key not found: :published_at
# ~>
# ~> xmptmp-in6215jUt.rb:5:in `fetch'
# ~> xmptmp-in6215jUt.rb:5:in `<main>'

The partial safe navigation we've employed here says something very specific and nuanced:

"We don't know if we're going to have RSS info. There's a chance it might be missing entirely. But if we do have it, then we expect it to have a certain shape, and we need to know if our expectation is incorrect".

I think it's often very tempting, when introducing safe navigation, to go all-or-nothing with it. If we're going to make the code partially forgiving, why not make it totally forgiving?

I've obsessed over this one line of code today because I want to encourage you to think very carefully about what assumptions you're encoding into each step in your data paths. It's fine to introduce some safe navigation when you know that a hunk of information might be optional. But the fragment of date being optional doesn't imply that every part of the fragment is also optional. Think about whether there is an expected structure to the optional information. If there is, make the optional parts of your path safe, and the non-optional parts strict.

Happy hacking!

Responses