Ruby Safe Navigation

Ruby safe navigation, especially in long chains, can be difficult to read and can hide some subtle edge cases.

Safe navigation

Consider a scenario where the following is true:

  1. Users are guaranteed to have an address
  2. Addresses are guaranteed to have a zip code

Given an optional user, we want to either get their zip code or return nil. Using very explicit code, we might write that as:

if user
  user.address.zip
else
  nil
end

But we are Rubyists and want to write pleasant, terse code. We turn to the safe navigation operator and refactor our code to this one-liner:

user&.address&.zip

However, behavior here is subtly different.

Conditional equivalence

When comparing various syntactic sugars for conditional logic, I find it helpful to convert them to a standardized if/else form. When doing that with the safe navigation chain we defined, we can see that it is subtly different than the conditional code we started with.

if user
  if user.address
    user.address.zip
  else
    nil
  end
else
  nil
end

The safe navigation version introduces extra uncertainty. Despite knowing that we have a user present, our code is uncertain whether or not the user has an address. This uncertainty leads to the extra nested condition.

Invalid paths

By using syntactic sugar we’ve introduced some extra paths through out app that didn’t exist in our original requirements. We are now faced with a choice. We can re-write our code to better match the reality we are trying to model.

Alternatively, we may realize our original requirements are incorrect and that we do need an extra nil check for the address. If we make this choice, we must make sure to add tests for the new edge case we’ve discovered.

Propagating vs producing

The lonely operator can act in one of two roles in a method chain:

Guarding methods that produce uncertainty. For example uncertain1&.uncertain2&.uncertain3. When using the lonely operator in this manner, each call in the chain is equivalent to a nested condition.

Propagating uncertainty down the chain. Once we have a value that can possibly be nil, every method call downstream of it also needs to check for nil. For example: uncertain&.certain1&.certain2. This is also equivalent to nested conditionals but often, what we actually mean to express is a single condition. This easily spills into defensive coding.

The tricky thing with &. is that, when reading the code, one can’t tell which of these two behaviors the author intended. To make things more complex, a chain of &. might have a mix of both behaviors. Looking back at our original problem user&.address&.zip, one can’t tell whether or not User#address is a nullable method or not.

Alternatives

When only the first item in a chain is nullable, we can use && instead of &. to more accurately express our intention.

user && user.address.zip

Beyond just using different syntax, there is also an opportunity to refactor. The chain of non-nullable methods can safely be extracted out. This likely results in cleaner code and also satisfies the law of demeter.

class User
  def zip
    address.zip
  end
end

With the given refactor we can now call user&.zip which is equivalent to our original if/else condition.

Valid uses

So should we avoid &. altogether? No. It has some valid uses cases.

  1. When every method in the chain might produce nil nothing&.is&.certain (make sure you have test coverage for all the edge cases!).
  2. As the final method call user&.zip.

Conclusion

Long chains of &. are usually a symptom of broader issues in a codebase such as defensive code or leaking responsibilities. In moderation, &. is a helpful tool but make sure to consider some of the other tools in your toolbox too.