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:
- 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 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.
- 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
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.
Want to upgrade your project?
If your team needs help with upgrading your Rails project, learn more about how thoughtbot can help.