/ Tags: RUBY 3 / Categories: RUBY

Safe Navigation, dig, and fetch — Choosing the Right Nil Guard

user&.profile&.address&.city looks defensive and reads as careful code. It’s also a sentence that says “I don’t know which of these can be nil, so I’ve assumed all of them can” — and when the result comes back nil, you have no idea which link in the chain broke. Ruby gives you four distinct tools for absent values, and picking the wrong one converts a loud failure into a silent wrong answer.

The Four Tools


Tool Returns on miss Raises? Use for
&. nil No A receiver that’s legitimately optional
dig nil No Traversing nested data of unknown shape
fetch Block value, default, or raise Yes, by default A key you expect to exist
try (Rails) nil No Duck-typed dispatch on unknown objects

The distinction that matters most is the last column of the “Raises?” question. fetch is the only one that tells you when your assumption was wrong.

&. — The Safe Navigation Operator


Example:

user&.name
# equivalent to
user.nil? ? nil : user.name

Note it checks for nil, not for falsiness. false&.to_s returns "false", not nil.

It short-circuits the whole chain
user&.profile.address     # ✗ NoMethodError if user is nil
user&.profile&.address    # ✓

The first line is a common mistake. &. only guards the call it’s attached to — if user is nil, user&.profile returns nil, and then .address is called on nil without protection. Once you start a chain with &., every subsequent link needs it too. That’s a strong signal you should restructure rather than continue.

Assignment works
user&.name = "Ada"    # no-op if user is nil
Where it’s wrong

The abuse case is using it to silence an error you don’t understand.

# If order should always have a customer, this hides a data integrity bug
order&.customer&.email

If order came from Order.find, it cannot be nil — find raises. If customer is a required belongs_to, it cannot be nil either. Two &. operators here don’t protect against anything; they guarantee that when your data is broken, you get a nil deep in a view instead of an exception at the source.

The test to apply: can this legitimately be absent as part of normal operation? If yes, &.. If no, let it raise.

dig — Traversing Nested Structures


Example:

response = {
  data: {
    user: {
      profile: { city: "Kathmandu" }
    }
  }
}

response.dig(:data, :user, :profile, :city)   # => "Kathmandu"
response.dig(:data, :account, :name)          # => nil, no error

Works on arrays, mixed structures, and anything defining dig:

data = { users: [{ name: "Ada" }, { name: "Grace" }] }
data.dig(:users, 1, :name)   # => "Grace"
data.dig(:users, 9, :name)   # => nil
The gotcha: it raises on non-diggable intermediates
{ a: "string" }.dig(:a, :b)
# => TypeError: String does not have #dig method

dig returns nil when a key is missing, but raises when it finds a value that can’t be dug into. This bites when parsing an API that returns a string where you expected an object — which is exactly the situation you used dig to defend against.

For genuinely unpredictable input:

def safe_dig(obj, *keys)
  keys.reduce(obj) do |current, key|
    break nil unless current.respond_to?(:dig)
    current.dig(key)
  end
end
Custom objects can be diggable
class Config
  def initialize(data) = @data = data
  def dig(*keys) = @data.dig(*keys)
end

fetch — When Absence Is a Bug


This is the underused one, and the one that prevents the most production incidents.

Example:

config = { host: "localhost", port: 5432 }

config[:timeout]                  # => nil, silently
config.fetch(:timeout)            # => KeyError: key not found: :timeout
config.fetch(:timeout, 30)        # => 30
config.fetch(:timeout) { 30 }     # => 30, block only evaluated on miss

The block form matters when the default is expensive:

cache.fetch(key) { expensive_computation }   # not computed on a hit
cache[key] || expensive_computation           # also fine, but breaks when the cached value is false
Why the KeyError is the point
# Silent — a typo produces nil, which flows downstream
retries = settings[:max_retries]
attempt_count.times { ... }   # nil.times => NoMethodError, ten frames away

# Loud — the typo fails at the point of the mistake
retries = settings.fetch(:max_retries)

A KeyError naming the missing key, raised at the line that made the assumption, is worth far more than a NoMethodError on nil somewhere else. Use fetch for configuration, for keyword-ish option hashes, for API responses whose contract you rely on, and for ENV.

ENV.fetch("DATABASE_URL")                # fails fast at boot if unset
ENV.fetch("REDIS_URL", "redis://localhost:6379")
ENV["DATABASE_URL"]                      # nil in production, discovered at 3am

Arrays have it too:

[1, 2, 3].fetch(10)        # IndexError
[1, 2, 3].fetch(10, :none) # => :none

try and When Not to Use It


Rails’ try predates &. and is mostly obsolete.

user.try(:name)     # nil if user is nil OR if user doesn't respond to :name
user&.name          # nil if user is nil; NoMethodError if it doesn't respond

try swallows NoMethodError, which means a typo in the method name returns nil instead of failing. That’s almost never what you want.

user.try(:naem)     # => nil, silently wrong
user&.naem          # => NoMethodError: undefined method 'naem'

Use &. by default. try earns its place only in genuine duck-typing, where the object might be one of several types with different interfaces — and respond_to? usually reads better there anyway. Note try! behaves like &. for the method-missing case, raising rather than swallowing.

Restructuring Instead of Guarding


Long guard chains are usually a design signal. Three ways out.

1. Delegate with allow_nil
class User < ApplicationRecord
  has_one :profile
  delegate :city, :country, to: :profile, allow_nil: true, prefix: true
end

user.profile_city   # nil-safe, and the chain is declared once
2. Null objects
class NullProfile
  def city = "Unknown"
  def country = "Unknown"
  def complete? = false
end

class User < ApplicationRecord
  def profile = super || NullProfile.new
end

user.profile.city   # always works, no guard anywhere

Null objects remove nil checks from every call site at the cost of one class. They’re the right answer when the absent case has sensible behaviour, and the wrong answer when absence should stop the operation.

3. Ask the question once
# Instead of guarding at every use
return unless user&.profile

city = user.profile.city
country = user.profile.country

One guard at the top, plain access afterwards. This is usually the clearest option and it’s the one people reach for least.

Pro-Tip: When a chain of &. returns nil and you need to know which link broke, don’t add puts at each level — use then (or yield_self) to make the chain inspectable. user.then { p _1; _1&.profile }.then { p _1; _1&.address }&.city prints each intermediate without restructuring the expression. Better yet, treat the need for this as the signal it is: if you can’t tell which link failed, the chain is doing too much. Extract it into a method whose name states the intent — user_city(user) — and inside that method use explicit guards with distinct early returns. The debugging problem disappears because each failure has its own line, and the call site reads better than the chain did.

Conclusion


The choice is about what should happen when the value is absent, and that’s a design decision rather than a syntax preference. Use &. where absence is a legitimate state, dig for genuinely unknown nested data, and fetch — the one people skip — everywhere absence means someone made a mistake, because a KeyError at the point of the wrong assumption beats a NoMethodError ten frames downstream. Skip try in new code. And when the guards start stacking three deep, that’s not a nil problem; it’s a structure problem, and a delegation, a null object, or a single early return will fix it properly.

FAQs


Q1: Does &. work with operators like []?
Yes — hash&.[](:key) works but reads badly. hash&.dig(:key) is the same thing and much clearer.

Q2: Is there a performance difference between &. and try?
Yes, meaningfully. &. is a VM-level instruction with a nil check; try is a Ruby method call that also does a respond_to? check. In a loop the difference is measurable, and &. also isn’t a Rails dependency.

Q3: What’s the difference between fetch with a default and fetch with a block?
The default argument is evaluated eagerly whether or not it’s used; the block is only called on a miss. Use the block when the default is expensive or has side effects.

Q4: Should dig be used on ActiveRecord objects?
dig is defined on Hash, Array, Struct, and OpenStruct, not on ActiveRecord models. For associations, use delegation with allow_nil — it expresses the relationship rather than treating the model as a nested hash.

Q5: Is Hash#fetch slower than Hash#[]?
Marginally, since it handles the missing-key path. The difference is nanoseconds and irrelevant against the cost of a bug that ships nil into production.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More