Ruby's standard library is wide, and most of its expressive power is genuinely useful — when chosen deliberately. This chapter names the idioms that make intent clear, pipelines readable, and code honest, and it names the traps that make the same surface clever instead of clear.
# frozen_string_literal: true
# typed: strict
sig { params(orders: T::Array[Order]).returns(T::Array[LineItem]) }
def discounted_items(orders)
eligible = orders.select { |o| o.status == "confirmed" }
line_items = eligible.flat_map(&:line_items)
discounted = line_items.select { |li| li.discount_cents > 0 }
discounted.map { |li| li.tap { |x| Metrics.increment("discount.applied", sku: x.sku) } }
end
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(Money) }
def extract_total(payload)
case payload
in { total_cents: Integer => cents, currency: String => currency }
Money.new(cents:, currency:)
in { total_cents: Integer => cents }
Money.new(cents:, currency: "USD")
end
endeligible, line_items, and discounted name the three pipeline stages so each transformation is legible on its own (7.1). The &:line_items shorthand names a single no-argument method call (7.2). tap threads inspection through the chain without breaking it (7.3). case/in pattern matching in extract_total deconstructs the hash structurally and handles optional fields without conditional gymnastics (7.4). Every hash uses shorthand symbol keys (7.10), and no literal array sugar is in sight (7.13).
7.1 — Build pipelines with map/select/reject/reduce/find; reach for each only for effects or early exit.
Reasoning, step by step:
map,select,reject,reduce, andfinddeclare intent: a reader knows at a glance whether a block transforms, filters, accumulates, or searches.eachis a blank verb — it conveys nothing about what the loop produces. When a loop transforms and assigns into an outer collection, replace it withmaporreduceso the structure is visible.- Name each pipeline stage with a local variable when the chain exceeds roughly three steps. The name is the documentation; a long fused method chain forces the reader to mentally execute every step before understanding the whole.
eachis correct for pure effects (writing to a log, enqueueing a job) and forbreak/nextearly exits — things the functional operators cannot express.
Worked example:
# frozen_string_literal: true
# typed: strict
# avoid: blank verb, mutates outer array
results = []
line_items.each { |li| results << li.amount if li.fulfilled? }
# prefer: intent named, no mutation
fulfilled_amounts = line_items.select(&:fulfilled?).map(&:amount)Enforcement: review; RuboCop Style/MapIntoArray and Style/EachWithObject surface common each-replaces-map patterns.
Reasoning, step by step:
items.map { |i| i.amount }says the same thing asitems.map(&:amount)— one token shorter, no named variable the reader has to track, intent instantly clear.- The idiom applies exactly when the block takes one argument and calls one no-argument method on it, with no branching and no further computation.
- Do not stretch it:
items.map { |i| i.amount * 100 }has arithmetic and needs the block form; forcing&:amountand chaining amap(&:* 100)is unreadable.
Worked example:
# frozen_string_literal: true
# typed: strict
skus = line_items.map(&:sku)
fulfilled = line_items.select(&:fulfilled?)
totals = orders.map(&:total_cents).reduce(0, :+)Enforcement: RuboCop Style/SymbolProc (enabled).
7.3 — Use tap for side-effecting inspection inside a chain; use then to pipe a value through an expression.
Reasoning, step by step:
tapyields the receiver to a block and returns the receiver unchanged. It is the right hook for a log statement, a metric increment, or a debugger breakpoint that must not break the chain — the chain does not know the tap was there.then(aliasedyield_self) yields the receiver to a block and returns the block's return value. Use it to fold an expression that does not fit the receiver's own API into a pipeline:raw.then { |s| JSON.parse(s) }keeps the chain linear where a variable assignment would break it.- Do not use
tapto mutate the receiver and carry the mutation forward invisibly; that is a hidden side effect, not an inspection. Mutation belongs in an explicit assignment.
Worked example:
# frozen_string_literal: true
# typed: strict
order
.tap { |o| Rails.logger.info("processing order #{o.id}") }
.line_items
.select(&:in_stock?)
.map(&:sku)
.then { |skus| Inventory.reserve(skus) }Enforcement: review; tap mutation is rejected at code review as a hidden side effect.
Reasoning, step by step:
case/inpattern matching deconstructs hashes, arrays, andDataobjects structurally. It replaces a tree ofdig/[]/fetchcalls andifguards with one declarative match expression that is both shorter and harder to misread.Hashpatterns (in { key: }) and array patterns (in [first, *rest]) reduce entire parse-and-validate blocks to a single match arm. Add=> variableto bind matched values directly in the pattern.- One-line
=>(the rightward assignment pattern) binds a value from a deconstruct without branching — useful at the top of a method where the input shape is asserted, not selected from multiple cases. - Pin operator
^matches against an existing variable; use it to guard against a specific runtime value in a structural pattern.
Worked example:
# frozen_string_literal: true
# typed: strict
sig { params(event: T::Hash[Symbol, T.untyped]).returns(T.nilable(Order)) }
def handle_event(event)
case event
in { type: "order.created", payload: { order_id: String => id, customer_id: String => cid } }
Order.find_by(id:, customer_id: cid)
in { type: "order.cancelled", payload: { order_id: String => id } }
Order.cancel(id)
else
nil
end
endEnforcement: review; prefer case/in over nested fetch/dig chains for structural decomposition.
Reasoning, step by step:
for x in collectionleaks the loop variable into the enclosing scope after the loop finishes. Block iterators (each,map, etc.) scope the block parameter to the block; no leakage.foris syntactic sugar overcollection.eachbut adds indirection without adding information. Every reader has to recall that equivalence; a block iterator states the pattern directly.forprovides nobreakvalue, no block-level rescue, and no way to pass the block further — strictly less capable and more surprising than its iterator equivalent.
Worked example:
# frozen_string_literal: true
# typed: strict
# avoid
for item in line_items
process(item)
end
# prefer
line_items.each { |item| process(item) }Enforcement: RuboCop Style/For (enabled, EnforcedStyle: each).
7.6 — Use { ... } for single-line blocks and do ... end for multi-line; never chain do...end; never write multi-line { }.
Reasoning, step by step:
{ ... }has higher precedence thando ... end. On a single line this is irrelevant; in a method chain, ado ... endblock binds to the last receiver in the chain, not tomap— silent re-parsing of intent. Use{ }for any block that participates in a chain.- Multi-line
{ }is visually ambiguous: the closing}floats far from its opening line and readers must scan to find it, unlikedo/endwhich reads like prose structure. - A
do ... endblock attached mid-chain is the mirror error:items.map do ... end.select { }attachesdo...endtoitems, notmap. Make the chain use{ }throughout.
Worked example:
# frozen_string_literal: true
# typed: strict
# single-line: brace block
totals = orders.map { |o| o.total_cents }
# multi-line: do...end
orders.each do |order|
notify(order)
Metrics.increment("order.notified")
end
# chain: brace throughout
orders
.select { |o| o.confirmed? }
.map { |o| o.total_cents }Enforcement: RuboCop Style/BlockDelimiters (EnforcedStyle: line_count_based).
Reasoning, step by step:
- Opening
String,Integer,Array, or any core class globally changes behaviour for every gem and every caller in the process — including ones written months later by someone who does not know the patch exists. The impact radius is the entire Ruby process. - Refinements (
refine+using) scope the patch to the files that explicitly opt in withusing. The impact radius is the one file. A why-comment on theusingline documents the tradeoff so it can be revisited. - Even refinements are a last resort. Before reaching for one, ask: can an adapter method, a plain module function, or a decorator class express the same thing without patching?
Worked example:
# frozen_string_literal: true
# typed: strict
module CentsConversion
refine Integer do
sig { returns(Money) }
def to_money
Money.new(cents: self, currency: "USD")
end
end
end
# In the one file that genuinely needs it:
using CentsConversion # why: legacy report adapter; remove when report is retired (2026-Q3)Enforcement: RuboCop Style/MonkeyPatchingProtection; global reopens of core classes are rejected at review.
Reasoning, step by step:
define_method,method_missing/respond_to_missing?, and dynamicsendtricks defer errors to runtime and hide the API surface from Sorbet, from documentation tools, and from every reader who did not write the meta-code. The compiler cannot type-check what it cannot see.public_sendis the sanctioned dynamic dispatch: it respectsprivate/protectedvisibility and raisesNoMethodErroron an unknown name — a loud failure rather than a silent dispatch to an unintended private method.ruby -wenables extra warnings: undefined instance variables, ambiguous literals, unused variables. Code should produce zero warnings — every suppressed warning is a potentialnilor dead branch hiding in production.- When metaprogramming genuinely earns its keep (a plugin system, a test double framework), contain it in one module, document the contract with a
sig, and gate the file with a why-comment.
Worked example:
# frozen_string_literal: true
# typed: strict
# avoid: Sorbet cannot type the generated methods; errors are runtime-only
%w[draft confirmed cancelled].each { |s| define_method(:"#{s}?") { status == s } }
# prefer: explicit, typed, grep-able
sig { returns(T::Boolean) }
def draft? = status == "draft"
sig { returns(T::Boolean) }
def confirmed? = status == "confirmed"
sig { returns(T::Boolean) }
def cancelled? = status == "cancelled"Enforcement: review; Method#public_send over send for dynamic dispatch; define_method and method_missing require a why-comment and architecture approval.
Reasoning, step by step:
- Plain
<<HEREDOCpreserves all leading whitespace, including the indentation of the surrounding code. The string carries invisible spaces that leak into error messages, SQL, and rendered templates. <<~strips the common leading indentation, so the string content can be indented to match the surrounding code without the indentation becoming part of the value.- The closing delimiter goes on its own line at the base indentation level. Trailing
.stripor.chompis still available for final newline handling but is not needed for indentation.
Worked example:
# frozen_string_literal: true
# typed: strict
sig { params(order: Order).returns(String) }
def cancellation_notice(order)
<<~MSG
Your order #{order.id} has been cancelled.
A refund of #{order.total_cents / 100.0} will appear within 5 business days.
Contact support@dexpace.com with questions.
MSG
endEnforcement: RuboCop Style/HeredocDelimiterNaming and Layout/HeredocIndentation (enabled); <<HEREDOC without squiggly is a cop violation.
7.10 — Use shorthand symbol hash keys; prefer symbols over strings as hash keys; use Hash#fetch with a default for absent keys.
Reasoning, step by step:
{ name: "Acme" }is shorter than{ :name => "Acme" }and unambiguous: shorthand keys must be symbols, so the rocket form is reserved for non-symbol keys ({ "content-type" => "application/json" }) where the distinction matters visually.- Symbol keys are frozen and interned; they produce stable
object_idvalues and zero GC pressure on repeated use as hash keys. String keys allocate a new object per literal. hash[:key]returnsnilon a missing key; thenilpropagates silently until something blows up far from the original miss.hash.fetch(:key)raisesKeyErrorimmediately at the source. Supply a default block for keys that legitimately may be absent:hash.fetch(:discount, 0).
Worked example:
# frozen_string_literal: true
# typed: strict
sig { params(attrs: T::Hash[Symbol, T.untyped]).returns(Order) }
def build_order(attrs)
Order.new(
id: attrs.fetch(:id),
customer: attrs.fetch(:customer),
total: attrs.fetch(:total_cents, 0),
)
endEnforcement: RuboCop Style/HashSyntax (EnforcedStyle: ruby19); Hash#fetch over [] on external/parsed input enforced at review.
7.11 — Use string interpolation over concatenation; wrap @ivars and $globals in {} inside interpolation; never call .to_s inside interpolation.
Reasoning, step by step:
- String concatenation (
"Order " + id.to_s + " failed") requires explicit.to_scoercion, a+for every junction, and allocates an intermediate string per+. Interpolation ("Order #{id} failed") coerces automatically viato_s, reads in document order, and allocates one string. - Without braces,
"#@total failed"is legal but ambiguous — readers must know the interpolation ends at the first non-identifier character."#{@total} failed"is unambiguous and grep-able. "#{value.to_s}"callsto_stwice: interpolation already callsto_s. Remove the inner call; it is noise.
Worked example:
# frozen_string_literal: true
# typed: strict
# avoid
msg = "Order " + order.id.to_s + " for " + customer.name.to_s + " failed"
# prefer
msg = "Order #{order.id} for #{customer.name} failed"
# ivar: always brace
note = "Balance is #{@balance_cents} cents"Enforcement: RuboCop Style/StringConcatenation and Style/RedundantInterpolation (both enabled); {} around ivars and globals enforced by Style/InterpolationCheck.
Reasoning, step by step:
DateTimeis a legacy class that models civil time in a combined date-and-time object without timezone awareness.Timein Ruby 4.0 is UTC-aware, supports nanosecond precision, and is the foundation forActiveSupport::TimeWithZone. UseTimeeverywhere and keep timestamps in UTC.Time.parse(s)accepts dozens of ambiguous formats and falls back to locale-dependent parsing when the format is unclear — a source of silent misparse.Time.iso8601(s)raisesArgumentErroron anything that does not conform to ISO 8601; the failure is immediate and at the source.- Prefer
Time.now.utcoverTime.now; store and compare in UTC; convert to a local zone only at presentation boundaries.
Worked example:
# frozen_string_literal: true
# typed: strict
# avoid
created_at = DateTime.parse(params[:created_at])
expires_at = Time.parse(params[:expires_at])
# prefer
created_at = Time.iso8601(params.fetch(:created_at)).utc
expires_at = Time.iso8601(params.fetch(:expires_at)).utcEnforcement: RuboCop Style/DateTime (prefer Time); review rejects Time.parse for known ISO 8601 inputs.
7.13 — Prefer plain literal arrays over %w/%i; use first/last over [0]/[-1]; use &&/||/! over and/or/not.
Reasoning, step by step:
%w[draft confirmed cancelled]is a second string-array literal syntax with its own escaping rules. Plain["draft", "confirmed", "cancelled"]is the same thing, grep-able as quoted strings, and requires no secondary syntax. This is our recorded deviation from Airbnb (see README deviations ledger).items[0]returnsnilon an empty array without signaling the access;items.firstreturnsnilidentically but reads as intent. More importantly,items.first(n)anditems.last(n)generalize to a slice cleanly, while[0]/[-1]do not.and/or/nothave lower precedence than assignment, which produces non-obvious parse trees:x = true and falseassignstruetox.&&/||/!have the precedence operators programmers expect from every C-family language. Use them in all boolean expressions; reserveand/orfor flow control only (and even then, preferif/unless).
Worked example:
# frozen_string_literal: true
# typed: strict
# avoid
STATUSES = %w[draft confirmed cancelled]
head = items[0]
tail = items[-1]
valid = user.active? and user.verified? # assigns true, then evaluates and
# prefer
STATUSES = ["draft", "confirmed", "cancelled"].freeze
head = items.first
tail = items.last
valid = user.active? && user.verified?Enforcement: RuboCop Style/WordArray (EnforcedStyle: brackets), Style/SymbolArray (EnforcedStyle: brackets), Style/AndOr (EnforcedStyle: always); first/last over index literals enforced at review.
- Formatting baseline, 2-space indentation, 100-column limit, double quotes, trailing commas, and
rubocop-airbnbcop configuration: 01-formatting-and-tooling.md. - Effect-verb naming, predicate
?suffix, and nois_/get_prefixes: 02-naming-conventions.md. # typed: strict,sigon every method,T.let/T.castwith why-comments, andHash#fetchas the nil-discipline tool: 03-type-safety-and-nil-discipline.md.freezeon constants,||=caveats, and no@@classvariables: 04-variables-and-declarations.md.- 25-line method cap,
public_sendoversend, guard clauses, and pure-by-default methods: 05-methods.md. Data.definevalue objects,class << selffor class methods,T::Enumfor closed polymorphism: 06-classes-and-data-modeling.md.StandardErrorsubclasses,raisewith class and message, and no silent rescue: 08-error-handling.md.- Lazy enumerators, allocation hygiene, and
sizeovercountfor performance: 15-performance.md.