Skip to content
← All posts
5 min read Dawid Skłodowski

Ruby 2.3: frozen string literals and the safe-navigation operator

Ruby 2.3.0 lands on Christmas Day with two changes you will type every day — the frozen-string-literal pragma and the &. safe-navigation operator — plus dig, did_you_mean, and more. A practical tour.

Ruby 2.3.0 has arrived on Christmas Day, as it does, and this one is full of small features you will actually type. Where the last couple of releases were mostly about the garbage collector working harder behind the scenes, 2.3 changes the code you write — a new operator, a new file-level pragma, a couple of genuinely handy methods, and a friendlier experience when things go wrong. Here is the practical tour.

The safe-navigation operator: &.

This is the headline, and it is borrowed from C# and Groovy where it has long earned its keep. The “lonely operator” &. calls a method only if the receiver is not nil, returning nil instead of blowing up:

# before — the defensive dance
name = user && user.profile && user.profile.name

# Ruby 2.3
name = user&.profile&.name

If user is nil, the whole expression short-circuits to nil instead of raising NoMethodError: undefined method 'profile' for nil. This kills one of the most common, ugly patterns in Ruby — the chain of && guards, or the try(:method) calls Rails developers reach for. It reads cleanly and says exactly what it means: “call this if there’s something to call it on.”

A word of caution, because it is easy to overuse: &. is a tool for the cases where nil is a legitimate, expected value (an optional association, a config that might be absent). It is not a way to paper over nils that should never occur — if a value being nil indicates a bug, you want the NoMethodError to surface it, not a &. to swallow it into a silent nil that fails confusingly three layers away. Use it to handle expected absence, not to hide unexpected absence.

Frozen string literals: a pragma and a future

The second big change is the frozen_string_literal pragma — a magic comment at the top of a file that makes every string literal in it frozen (immutable):

# frozen_string_literal: true

GREETING = "hello"   # this string is frozen — cannot be mutated
GREETING << " world" # => RuntimeError: can't modify frozen String

There are two reasons this matters. The first is performance: in Ruby, every string literal normally allocates a brand-new object each time it is evaluated, so a literal in a hot loop creates garbage on every pass. A frozen literal can be a single cached, shared, immutable object — fewer allocations, less GC pressure. For literals used as hash keys and constants in hot paths, that is a real saving. The second is correctness: immutable strings cannot be accidentally mutated by some far-off code holding a reference, which removes a category of spooky-action-at-a-distance bugs.

In 2.3 this is opt-in per file via the pragma, and it is clearly the direction of travel — the long-term plan is for frozen string literals to become the default in a future Ruby. Adopting the pragma now future-proofs your files and earns the performance benefit today. The one adjustment: where you genuinely need a mutable string, allocate one explicitly with String.new or +"..." (the unary plus returns a mutable copy).

dig: reaching into nested structures safely

Hash#dig and Array#dig are small but you will use them constantly. They walk a nested structure by a sequence of keys, returning nil the moment any level is missing instead of raising:

data = { user: { address: { city: "Kraków" } } }

data.dig(:user, :address, :city)   # => "Kraków"
data.dig(:user, :company, :name)   # => nil  (no raise on the missing :company)

# vs the old, fragile:
data[:user][:company][:name]       # => NoMethodError on nil[:name]

This is the safe-navigation idea applied to data structures, and it is a godsend for the deeply-nested hashes you get from parsing JSON API responses, where any level might be absent. One call replaces a pile of && checks or fetch chains.

did_you_mean: typos get a hint

A quality-of-life win for everyone: the did_you_mean gem is now bundled and on by default, so a misspelled method or variable name comes back with a suggestion instead of a bare error:

NoMethodError: undefined method `lenght' for "hello":String
Did you mean?  length

It is a small thing that saves real time, especially for newcomers, and it costs you nothing.

The smaller additions

A few more worth knowing:

  • Enumerable#grep_v is the inverse of grep — select everything that does not match: lines.grep_v(/^#/) drops comment lines.
  • Comparable#clamp constrains a value to a range: score.clamp(0, 100) instead of nested min/max calls.
  • Hash#to_proc lets a hash act as a lookup block: ids.map(&lookup_hash) maps each id through the hash.
  • The “frozen string” deprecation tooling (--enable-frozen-string-literal and a debug mode) helps you find code that would break under future frozen-by-default behaviour.

Should you upgrade?

Yes — and unusually, this is a release whose value shows up in your editor, not just your metrics. The safe-navigation operator and dig will clean up nil-handling code you write every day; the frozen_string_literal pragma earns a performance benefit and prepares you for the future default; and did_you_mean quietly makes every error message more helpful. None of it is risky to adopt — &., dig, and the niceties are purely additive, and the frozen-string pragma is opt-in per file so you can roll it out gradually.

The pattern of recent Ruby releases continues: the runtime keeps getting leaner and the language keeps getting small, sharp ergonomic improvements that make everyday code cleaner. 2.3 is a particularly nice batch of them — the kind of release where, a month after upgrading, user&.profile&.name and data.dig(...) already feel like they were always there.