Better null-check with Safe Navigation operator in Ruby

Better null-check with Safe Navigation operator in Ruby

SKYE Dang

If you are familiar with the Optional Chain operator (?.) of JavaScript, then you cannot miss the Safe Navigation Operator (&.) of Ruby.

Now let's dig into it!

What is a Safe navigation operator?

Have you ever seen this error before?

NoMethodError (undefined method 'foo' for nil:NilClass)

This is a common error that occurs whenever you try to call a property or method on an nil object in Ruby. Then you might have ended up writing lots of conditional statements to check for nil before accessing the property or method.

To overcome this problem, Ruby provides a shorthand operator called the Safe navigation operator (&.) or lonely operator that helps you avoid the NoMethodError, which was introduced in Ruby 2.3.0. The long code will become shorter and more readable:

# Without `&.`
user && user.job && user.job.company && user.job.company.address

# With `&.`
user&.job&.company&.address

A detailed example:

user = User.find_by(id:)
if user&.email
  puts "User's email: #{user.email}"
else
  puts "User has no email address"
end

In the code above, we're trying to access the email property of user object. However, if the user is nil, calling directly user.email will result in NoMethodError. By using the safe navigation operator (user&.email), it means:

  • if user is not nil, then call its email and print it out;
  • otherwise, return nil then print out "User has no email address".

Caution!

  • The safe navigation operator only works for nil objects. Otherwise, it will raise the NoMethodError for non-nil objects. This is also a feature that gives a right error for an unimplemented method.
'foo'&.email # => undefined method `email' for "foo":String
12345&.email # => undefined method `email' for 12345:Integer
false&.email # => undefined method `email' for false:FalseClass
  • Do not overuse it! The safe navigation operator can mask errors that should be caught and handled properly, which makes it harder to find bugs.
# Assume user not found
user = nil

# Instead of checking if user is nil, the flow still do below things,
# which cost resources and lack of error handling for important cases
send_notification(user&.email)
verify_company(user&.job&.company&.id)
get_direction(user&.address)

Why is it better than try?

If you're on Rails, or using ActiveSupport, you can use present? or try to check a nil object:

# With `present?`
user.present? && user.job.present? && user.job.company
# => true / false 

# With `try`
user.try(:job).try(:company)
# => nil / user.job.company

However, try will return nil for non-nil objects even if the method or property doesn't exist. Instead of try, try! is recommended instead, it works as the same as the safe navigation operator.

'foo'.try(:email) # => nil
'foo'.try!(:email) # => undefined method `email' for "foo":String

Benchmark

You can try the following code to compute the performance between try and &.:

require "benchmark"
require "active_support/all"

Benchmark.bm do |x|
  count = 1_000_000

  x.report "active_support try" do
    count.times { nil.try(:length) }
  end

  x.report "safe navigation &." do
    count.times { nil&.length }
  end
end

According to the results, the safe navigation operator &. is about four times faster than try, as it's a built-in of Ruby. It does not depend on ActiveSupport and other dependencies of Rails:

use time
active_support try 0.116621
safe navigation &. 0.034128

Summary

  • The safe navigation operator (&.) gives a shorter syntax, faster performance, making the code more readable and easier to maintain if used appropriately.
  • The safe navigation operator should only be used when nil is an acceptable result. Make sure that you're not using it as a substitute for proper error handling.
  • Using try or try! are slightly longer than the &., which can make it less readable. Despite that, they should still be used in the older versions that don't have the &. available.

References