Writing Cleaner Ruby — The Terser Syntax Patterns Worth Adopting
Ruby has quietly accumulated a set of shorthands over the 3.x series — numbered block parameters, hash value omission, rightward assignment, anonymous argument forwarding. Each removes a small amount of ceremony. Adopted carelessly they produce code only the author can read; adopted deliberately they remove the noise that was obscuring the logic. The difference is knowing which ones hold up under a second reader.
Hash Value Omission
When a hash key matches the name of a variable or method in scope, you can drop the value.
Example:
name = "Ada"
role = :admin
# Before
User.new(name: name, role: role)
# Ruby 3.1+
User.new(name:, role:)
It works anywhere a hash literal does, including keyword arguments and returns:
def summary
total = calculate_total
currency = account.currency
discounted = total < subtotal
{ total:, currency:, discounted: }
end
This is the single highest-value addition of the bunch, because the repetition it removes carried no information. name: name never told anyone anything.
Where it hurts: when the local name and the key name coincidentally match but conceptually differ, the shorthand hides a decision. And in a method with twenty locals, { total:, currency:, discounted: } requires the reader to go find where each came from. Keep the definitions close to the hash.
Numbered and it Block Parameters
Example:
# Explicit
users.map { |user| user.email }
# Numbered parameter (Ruby 2.7+)
users.map { _1.email }
# `it` (Ruby 3.4+)
users.map { it.email }
# Symbol-to-proc, which predates both and is still shortest here
users.map(&:email)
For a single method call, &:email remains the best form. _1 and it earn their place when the block does something a symbol can’t:
orders.select { _1.total_cents > 10_000 && _1.status == "pending" }
records.each_with_index.map { "#{_2 + 1}. #{_1.title}" }
it reads better than _1 in prose-like code and is limited to a single parameter. _1/_2 handle multiple. Both are for short blocks — one line, ideally one expression.
Where it hurts: any block over two lines. _1 three lines down from the { is a name with no declaration, and the reader has to scroll up to work out what it refers to. Nested blocks with _1 at two levels are genuinely ambiguous to read even though Ruby resolves them fine.
Endless Method Definitions
Example:
def total = line_items.sum(&:subtotal)
def admin? = role == :admin
def to_s = "#{name} <#{email}>"
def formatted_total = format("%.2f", total / 100.0)
Best suited to what they look like: definitions, not procedures. A one-expression method that computes and returns a value reads beautifully. Anything with a side effect reads like a mistake:
def save! = record.update!(status: :complete) # works, but the = suggests a value
Note the mandatory space around = when there are no parentheses, and that endless methods can’t take a rescue clause or a begin block.
Rightward Assignment and Pattern Matching
Example:
# Extract into locals with a pattern
response => { data: { user: { name:, email: } } }
puts name, email
# Fails loudly if the shape doesn't match
config => { database: { host:, port: } }
# NoMatchingPatternKeyError if :port is missing
That failure mode is the point. Compare with the dig equivalent, which silently produces nil and defers the error to somewhere else entirely. Rightward assignment asserts the shape and destructures in one line.
Use in when you want a boolean instead of an exception:
if response in { status: 200, data: { orders: [_, *] } }
process(response[:data][:orders])
end
Where it hurts: the arrow reads backwards to anyone who hasn’t seen it, and deeply nested patterns become their own puzzle. Two levels is comfortable; four is not.
Anonymous Argument Forwarding
Example:
# Ruby 3.0 — forward everything
def log_and_call(...)
Rails.logger.info("calling")
target.call(...)
end
# Ruby 3.2 — forward just the block, or just the splats
def wrap(&) = target.call(&)
def with_args(*) = target.call(*)
def with_kwargs(**) = target.call(**)
The full (...) form is genuinely useful for decorators and proxies where enumerating the signature adds nothing and would go stale. The partial forms are more debatable — def wrap(&) = target.call(&) saves seven characters and costs a reader who has to remember what a bare & means.
Adopt (...), be cautious with the rest.
Data and Struct Keyword Init
Example:
Point = Data.define(:x, :y)
p = Point.new(x: 1, y: 2)
p.with(y: 5) # => #<data Point x=1, y=5>
p.to_h # => { x: 1, y: 2 }
Config = Struct.new(:host, :port, keyword_init: true)
Data gives you an immutable value object with equality, to_h, deconstruct_keys for pattern matching, and a non-destructive with — in one line where a hand-written class took fifteen.
Safe Navigation and then
Example:
# Chain a value through transformations without intermediate locals
user
.then { Presenter.new(_1) }
.then { _1.render(format: :html) }
# Guard a whole pipeline
value&.then { expensive_transform(_1) }
then (aliased yield_self) is worth knowing mainly for readability of pipelines, and for making a chain debuggable — you can drop a p inside any then without restructuring.
Pro-Tip: Adopt these one at a time and enforce each with RuboCop rather than with code review.
Style/HashSyntaxwithEnforcedShorthandSyntax: alwayshandles value omission;Style/EndlessMethodhandles the one-liners. Autocorrect the codebase for one cop, commit it alone, and add the SHA to.git-blame-ignore-revs. The reason to do it this way rather than “everyone start using these” is consistency: a codebase where half the hashes use shorthand and half don’t is harder to read than one that consistently uses either. The syntax itself is worth less than the uniformity, and a linter delivers uniformity while a convention memo does not.
The Ones to Skip
Not every terse form is an improvement.
&.as a reflex. Covered at length elsewhere, but worth repeating: chained safe navigation hides which link was nil._1in blocks longer than a line. The parameter has no declaration to look at.- Single-character locals to match the brevity of the surrounding syntax. Terse syntax and terse names are unrelated; adopting one doesn’t license the other.
- Endless methods with side effects. The
=sets an expectation the body doesn’t honour. - Nested rightward assignment past two levels. Extract a method and pattern-match in two steps.
The test that works: read the line as though you wrote it eight months ago. If the shorthand still says what the code does, keep it. If you’d have to reconstruct what _1 refers to, it cost more than it saved.
Conclusion
Hash value omission and Data.define are near-unconditional wins — they delete ceremony that carried no meaning. Endless methods are excellent for pure computed values and misleading for anything with an effect. Numbered parameters and it belong in one-line blocks and nowhere else. Rightward assignment is the underused one: it asserts a shape and destructures in a single line, and it fails loudly where dig would fail silently. Pick them up individually, enforce each with a linter so the codebase stays uniform, and judge every one by whether a second reader gets the same meaning you did.
FAQs
Q1: Which Ruby version do I need for these?
Numbered parameters 2.7, endless methods and rightward assignment 3.0, hash value omission and Data 3.2, it 3.4. Set TargetRubyVersion in .rubocop.yml and it will only suggest what your version supports.
Q2: Does it conflict with a local variable named it?
An existing local or method named it takes precedence, so old code keeps working. Ruby emits a warning in 3.3 for blocks using a bare it to give you time to rename.
Q3: Is hash value omission slower?
No — it’s purely syntactic and produces identical bytecode to writing the value out.
Q4: Can endless methods be private?
Yes: private def total = calculate works, as does listing the name in a private call afterwards. The inline private def form reads best.
Q5: Should a public gem use the newest syntax?
Only if your gemspec’s required_ruby_version allows it. Using 3.4 syntax while claiming 3.0 support produces a syntax error at load time for anyone on an older Ruby — and syntax errors can’t be rescued.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀