Ruby safe navigation, especially in long chains, can be difficult to read and can hide some subtle edge cases.
Consider a scenario where the following is true:
- Users are guaranteed to have an address
- Addresses are guaranteed to have a zip code
Given an optional user, we want to either get their zip code or return
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:
However, behavior here is subtly different.
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.
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.
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.
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
&. 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.
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.
So should we avoid
&. altogether? No. It has some valid uses cases.
- When every method in the chain might produce nil
nothing&.is&.certain(make sure you have test coverage for all the edge cases!).
- As the final method call
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.