Ruby Idioms That Replace Five Lines With One — And When Not To
by Eric Hanson, Backend Developer at Clean Systems Consulting
The compression tradeoff
Idiomatic Ruby can communicate intent in fewer characters than almost any other language at the same level of abstraction. That's a feature when the idiom is widely known and the intent is clear. It's a liability when the idiom is obscure, the context is complex, or the one-liner hides a decision the reader needs to see.
The question for each of these isn't "is this clever?" — it's "does compressing this make the intent clearer or less clear?"
tap — inline side effects without breaking a chain
tap yields the receiver to a block and returns the receiver unchanged. The canonical use: inserting a side effect into a method chain without breaking it or introducing a variable:
# Before
user = User.new(params)
logger.debug("Creating user: #{user.email}")
user.save!
user
# After
User.new(params)
.tap { |u| logger.debug("Creating user: #{u.email}") }
.tap(&:save!)
More usefully, tap eliminates the "build, inspect, return" pattern:
# Before
def build_headers
headers = {}
headers["Content-Type"] = "application/json"
headers["Authorization"] = "Bearer #{token}"
headers
end
# After
def build_headers
{}.tap do |h|
h["Content-Type"] = "application/json"
h["Authorization"] = "Bearer #{token}"
end
end
When not to: tap inside a chain that's already three or four calls deep adds visual noise rather than removing it. And tap(&:save!) — passing a method reference to tap — only works for no-argument methods. The moment you need arguments, it falls back to a block anyway.
then / yield_self — transforming a value in a pipeline
then (aliased as yield_self, Ruby 2.6+) passes the receiver into a block and returns the block's return value — the opposite of tap. It's the right tool for expressing a linear transformation pipeline where each step produces a new value:
# Before
raw = fetch_data(id)
parsed = parse(raw)
validated = validate(parsed)
transform(validated)
# After
fetch_data(id)
.then { |raw| parse(raw) }
.then { |parsed| validate(parsed) }
.then { |validated| transform(validated) }
With method references this tightens further:
fetch_data(id)
.then(&method(:parse))
.then(&method(:validate))
.then(&method(:transform))
The pipeline form is worth it when each step is a meaningful named operation and the sequence reads as a description of the process. It's not worth it for two-step transformations where a local variable is clearer, or when the steps are anonymous lambdas that require reading the block body to understand.
When not to: then applied to a single transformation is just a verbose way to call a method. value.then { |v| v * 2 } is worse than value * 2 in every dimension. Also watch for then chained over nil — if any step returns nil unexpectedly, the next step receives nil silently. The pipeline has no short-circuit behavior built in.
Endless methods — one-liners for simple accessors
Ruby 3.0 introduced endless method definitions — methods defined without an end:
def full_name = "#{first_name} #{last_name}"
def admin? = role == "admin"
def to_s = "#<#{self.class} #{id}>"
These are syntactically complete method definitions. They work everywhere a regular method does: in classes, modules, on objects. The constraint is that the body must be a single expression.
They earn their place for pure computations and predicate methods where the implementation is obvious from the name. The signal is: if someone has to read the body to understand what the method does, it shouldn't be endless. If the name makes the body redundant, endless is fine.
When not to: Endless methods look unusual to developers unfamiliar with Ruby 3.0+ syntax — and that's most developers in codebases that started before 2021. If your team hasn't adopted them as a convention, introducing them unilaterally causes friction at code review. Also, endless methods cannot contain conditionals or rescue — the moment you need branching, you need end.
Pattern matching — structural destructuring
Ruby 3.x pattern matching with in is underused for one specific job it does cleanly: destructuring structured data with shape validation in one expression:
# Pulling fields from an API response hash
case response
in { status: "ok", data: { user: { id: Integer => id, email: String => email } } }
process_user(id, email)
in { status: "error", message: String => msg }
log_error(msg)
end
The in pattern simultaneously checks shape, checks type, and binds values to local variables. The equivalent without pattern matching is several lines of fetch, type checks, and variable assignments.
The one-liner form with => (rightward assignment, Ruby 3.0+) deconstructs a value directly:
response => { data: { user: { id:, email: } } }
# id and email are now bound as local variables
# raises NoMatchingPatternError if the shape doesn't match
When not to: Pattern matching on flat hashes with no nesting or type validation is verbose compared to fetch or direct access. The syntax earns its weight proportionally to the depth and complexity of the structure being matched. Shallow patterns are usually clearer with direct access.
then + result objects — the short-circuit pipeline
One combination worth knowing explicitly: then composes naturally with result objects to express pipelines that can fail at any step:
def process(input)
validate(input)
.then { |data| enrich(data) }
.then { |data| persist(data) }
end
If validate returns a Result::Failure, and each subsequent method checks result.ok? before proceeding, the chain short-circuits cleanly. This requires the step methods to understand the result protocol — they receive and return result objects, not raw values. It's more ceremony upfront, but it reads as a description of the happy path with failure handling implicit in the protocol.
The alternative is dry-monads' Do notation, which eliminates even the .then calls using yield inside a monadic context. That's a bigger convention commitment — worth it if the team is already using dry-rb, not worth introducing for one feature.
Conditional assignment idioms
Three related idioms, ordered by how often I see them misused:
||= — assign if nil or false. Covered in depth in the memoization article. The short version: safe for truthy values, wrong for values that can be nil or false.
&. (safe navigation) — call a method only if the receiver is not nil:
user&.email&.downcase
This is correct when nil is a genuinely expected value at any point in the chain. It's a smell when it papers over a design problem — a method that sometimes returns nil where it shouldn't, or an association that should always be present. &. should describe a legitimate optional, not suppress a NoMethodError that indicates something is wrong.
fetch with a default — access a hash key with an explicit fallback:
options.fetch(:timeout, 30)
Prefer this over options[:timeout] || 30 when nil and false are valid values for the key. fetch with a block defers the default computation:
config.fetch(:api_key) { raise "API key not configured" }
This is a clean pattern for required configuration — the error is explicit and fires at access time, not at the point where the nil value eventually causes a NoMethodError three calls later.
The compression heuristic
Before reaching for any of these idioms, one question: would a developer unfamiliar with this specific feature understand what this line does in under five seconds?
If yes, the compression earns its place. If not, the explicit five-line version communicates better, regardless of how idiomatic the short form is.
The idioms in this article are all worth knowing because they appear in other people's codebases whether you use them or not. But knowing an idiom and deploying it everywhere are different things. The codebases that read cleanest aren't the ones that use every Ruby feature — they're the ones where every line communicates its intent to the next reader without demanding a language reference.