Idioms, Style and Clean Code

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. Idiomatic Ruby is code that uses the language’s own vocabulary: blocks and Enumerable instead of counters, expressions instead of statements, small objects with clear names instead of long procedures with comments. Clean code is code whose next change is cheap. The two overlap most of the time, and where they differ this page says so. It covers the daily habits (naming, small methods, guard clauses, conditionals, iteration, return values, value objects, small idioms), how to comment, and how to improve existing code in small, test-protected steps.

Most advice here is convention, not language rule. The language rules are on the pages this one builds on: Design Philosophy and Coming from Other Languages explains the intent behind the language, Methods and Parameters and Calling Methods and Argument Forwarding define the parameter kinds used below, Control Expressions and Operators and Precedence the conditionals, Enumerable and Blocks, Procs and Lambdas the iteration vocabulary, Exceptions and Error Handling the error side of guard clauses, and Struct, Data and Value Objects and Equality, Hashing and Comparison the value-object tools. Visibility is in Visibility and Encapsulation. The tools that enforce much of this automatically are in Style, Linting and Static Analysis, the tests that make refactoring safe in Testing with Minitest, and the design principles that sit one level above idioms in SOLID and Design Patterns. Typical traps are collected in Common Mistakes and Gotchas.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The output shown in # => comments, and in the output blocks, is the real output. The style rules quoted come from the community Ruby Style Guide (https://rubystyle.guide/) and from RuboCop’s default configuration, both labelled as community material: neither is part of Ruby itself. Each file is self-contained and uses the names of the shelf library catalogue used throughout this section (Shelf, Book, Isbn, Author, Catalog, Loan), run with ruby -Ilib so that require "shelf" finds the gem (see the shelf gem source).

What "idiomatic" means in Ruby

Ruby’s design goal is programmer happiness and the principle of least surprise: code should do what a reader who knows the core library expects. In practice that gives four habits that every section below applies.

  • Everything is an object, and objects answer messages. 5.nonzero?, nil.to_a, "text".then { it.strip } and [1, 2].sum are ordinary calls. Prefer asking an object over testing its type, and rely on duck typing: if it responds to each, it is enumerable for your purposes.

  • Expressions over statements. if, case, begin, method bodies and blocks all produce a value. Using that value removes temporary variables and return keywords.

  • Say what, not how. books.select { ... }.map { ... } states the intent; a counter, an index and an accumulator state the mechanism. The mechanism is still available (and right) when the algorithm is the mechanism.

  • Small, named pieces. A method name is documentation that the interpreter keeps honest. Extract until the name is better than a comment would be.

None of these is a licence for cleverness. The measure is the next reader: a one-line chain of five Enumerable calls is idiomatic only if it is still readable in a code review. Idioms also change. What was idiomatic before the keyword argument separation of Ruby 3.0, before Data (3.2) or before the it block parameter (3.4) is now often a smell; the last section lists what older code and older books do differently.

Naming

Conventions

Names are the main API of a codebase. Ruby’s conventions are few and almost universal; RuboCop’s Naming cops enforce the mechanical ones, and the Ruby Style Guide Naming section is the reference for the rest.

What Convention Example

Local variables, method names, symbols, keyword arguments, file names

snake_case; files named after the class or module they hold

loan_days, due_date, reservation.rb

Classes and modules

CapitalCase; acronyms stay uppercase in the Style Guide

LoanPeriod, Shelf::Store::Memory, HTTPClient

Constants that are not classes or modules

SCREAMING_SNAKE_CASE

MAX_RENEWALS, GRACE_DAYS

Predicate methods

End in ?, no is_/has_/can_ prefix

renewable?, empty?, include?

The more dangerous twin of a method

End in !, only if a safer method exists

renew and renew!, sort and sort!

Conversion methods

to_ plus the target; to_s, to_h, to_a are the implicit protocol

reservation.to_h

Unused block parameters and variables

Leading _ (or _ alone); Ruby and RuboCop do not warn about them

_title, _year, _

Binary operators and their parameter

The parameter is named other for symmetric operators

def <=>(other)

Names should be English and describe what the object is or what the method does for the caller, not how it works. A getter is title, not get_title; a setter is title= (RuboCop’s Naming/AccessorMethodName flags get_ readers and set_ writers). A predicate says what is asked: overdue?, not is_overdue?. The default RuboCop configuration enforces this with Naming/PredicatePrefix; Ruby’s own empty?, include?, frozen? and nil? are the model.

Very short names are acceptable as the parameter of a one-line block (and a block that only calls one method is better written books.map(&:title)), but not in a method signature: Naming/MethodParameterName requires at least three characters, with a few allowed names such as io, id and to.

? and ! as they really are

The folklore says "a method ending in ? returns a boolean" and "a method ending in ! mutates its receiver". Neither is the rule, and treating them as rules produces wrong code.

  • ? means "asks a question". The result is truthy or falsy, not necessarily true or false. In the core library, Integer#nonzero? returns the number or nil, and File.size? returns a size or nil. Return a real boolean from your own predicates when callers may print or serialise the result, but do not insist on it when the truthy value is useful. RuboCop’s Naming/PredicateMethod (added in 1.76, still pending, so it is on only with NewCops: enable) checks that predicates return booleans and that non-predicates do not.

  • ! means "the more dangerous version of a method that has a safe version". Mutation is the most common danger, but not the only one: exit! skips at_exit handlers, and an update! that raises where update returns false is dangerous in the same sense. Do not add a bang to a method just because it mutates: Array#pop, Array#push and Hash#delete mutate and have no bang because there is no safe twin. Conversely, a bang method does not always change anything: [1, 2, 3].uniq! returns nil when it makes no change, so never chain on the result of a bang method.

The example defines a Reservation with a safe renew (returns a new object) and a dangerous renew! (raises), and shows the core-library cases that break the folklore.

# frozen_string_literal: true

require "shelf"

module Shelf
  # Names carry meaning: the reader should guess what a call does.
  class Reservation
    MAX_RENEWALS = 2

    attr_reader :book, :renewals

    def initialize(book, renewals: 0)
      @book = book
      @renewals = renewals
    end

    # Predicate: asks a question, has no side effects.
    def renewable? = renewals < MAX_RENEWALS

    # Safe version: returns a new object, never raises.
    def renew = renewable? ? self.class.new(book, renewals: renewals + 1) : self

    # Bang version: the more dangerous twin, raises when it cannot renew.
    def renew!
      raise Shelf::Error, "no renewals left for #{book.title}" unless renewable?

      renew
    end

    # Conversion methods use the to_ prefix and return a new value.
    def to_h = { title: book.title, renewals: }
  end
end

author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "The Dispossessed", author:)
reservation = Shelf::Reservation.new(book, renewals: 2)

reservation.renewable? # => false
reservation.renew.renewals # => 2
reservation.to_h # => {title: "The Dispossessed", renewals: 2}

begin
  reservation.renew!
rescue Shelf::Error => e
  e.message # => "no renewals left for The Dispossessed"
end

# Core predicates and bang methods do not follow the folklore either.
5.nonzero? # => 5
0.nonzero? # => nil
[1, 2, 3].sort! # => [1, 2, 3]
[1, 2, 3].uniq! # => nil
[1, 2, 3].pop # => 3

When both versions exist, the Style Guide recommends defining the safe one in terms of the dangerous one or the other way round, whichever keeps one copy of the logic. Here renew! guards and delegates to renew.

Small methods and clear parameters

One level of abstraction per method

A method should do one thing at one level of abstraction. The Style Guide’s limit is ten lines, ideally under five; the RuboCop defaults put numbers on it. These are the thresholds the examples on this page were linted against (all are RuboCop’s defaults, overridable in .rubocop.yml):

Cop Default What it measures

Metrics/MethodLength

10

Lines of code in a method body, blank lines and comments not counted

Metrics/AbcSize

17

Assignments, branches (calls) and conditions combined into one score

Metrics/CyclomaticComplexity

7

Number of independent paths through the method

Metrics/ParameterLists

5 (3 optional)

Parameters, keywords included; a maximum of 3 optional positional ones

Metrics/ClassLength

100

Lines in a class body

Layout/LineLength

120

Characters per line

The numbers are guidance: a long case that maps codes to names may be fine at fifteen lines, and a five-line method with four nested conditionals is not. When a method grows past the limit, read it for the sections that have a name (a comment above a paragraph of code is a method name waiting to be written) and extract those. Extracted helpers are usually private, so the public interface does not grow.

Parameters: keywords, defaults and parameter objects

Ruby offers four ways to pass information, and the idiomatic choice follows the call site:

  • One or two required positional parameters whose meaning is obvious from the method name: Isbn.new(digits).

  • Keyword arguments from the third parameter on, for every optional value, and for boolean flags. lend(book, true) tells the reader nothing; lend(book, renewable: true) does. Required keywords (days_late:) document what is mandatory; optional ones carry a default. Since Ruby 3.0 keywords are separate from positional hashes, so an options hash as the last parameter (options = {}) is a legacy pattern: the Style Guide recommends keywords instead, and RuboCop’s Style/OptionalBooleanParameter flags positional boolean defaults.

  • Hash and keyword value omission (3.1) when a variable already has the key’s name: Book.new(isbn:, title:, author:).

  • A parameter object when the same group of values travels together through several methods, or a list grows past three or four parameters. In modern Ruby this is a Data class (3.2): immutable, with keyword construction and value equality built in. Defaults can be given by overriding initialize with keyword defaults and calling super.

A method should also return one kind of value. A search that returns an Array for hits and nil or false for "no hits" forces every caller to test the type; return an empty Array. Mixed returns are the usual reason nil shows up in the wrong place.

The Search below shows these points together: a SearchOptions parameter object with defaults, a short public call that reads as a sentence, and three small private helpers, one per decision.

# frozen_string_literal: true

require "shelf"

module Shelf
  # Keyword arguments name the call site; Data groups related parameters.
  SearchOptions = Data.define(:title_contains, :limit, :sort_by) do
    def initialize(title_contains: nil, limit: 10, sort_by: :title)
      super
    end
  end

  # One level of abstraction per method, each short enough to read at a glance.
  class Search
    def initialize(catalog) = @catalog = catalog

    def call(options = SearchOptions.new)
      matching(options.title_contains)
        .sort_by { |book| sort_key(book, options.sort_by) }
        .first(options.limit)
    end

    private

    def matching(fragment)
      return @catalog.to_a if fragment.nil?

      @catalog.select { |book| book.title.downcase.include?(fragment.downcase) }
    end

    def sort_key(book, field) = field == :author ? [book.author.name, book.title] : [book.title]
  end
end

catalog = Shelf::Catalog.new
[["The Dispossessed", "Ursula K. Le Guin", "978-0-306-40615-7"],
 ["A Wizard of Earthsea", "Ursula K. Le Guin", "978-0-14-131026-8"],
 ["Dune", "Frank Herbert", "978-0-441-17271-9"]].each do |title, name, digits|
  catalog.add(Shelf::Book.new(isbn: Shelf::Isbn.new(digits), title:, author: Shelf::Author.new(name)))
end

search = Shelf::Search.new(catalog)
search.call.map(&:title) # => ["A Wizard of Earthsea", "Dune", "The Dispossessed"]
search.call(Shelf::SearchOptions.new(title_contains: "the", limit: 1)).map(&:title) # => ["The Dispossessed"]
search.call(Shelf::SearchOptions.new(sort_by: :author)).map(&:title)
# => ["Dune", "A Wizard of Earthsea", "The Dispossessed"]

Source: Data

Details to notice:

  • SearchOptions.new with no arguments is valid because the overridden initialize supplies defaults for all three keywords; a bare super forwards them to `Data’s own initialiser.

  • matching returns @catalog.to_a when there is no filter, so both branches return an Array. The early return is a guard clause (next section), not a stylistic habit.

  • sort_key is an endless method, introduced in Ruby 3.0. The Style Guide limits them to a single side-effect-free expression; anything longer takes a normal multi-line def.

  • The sort key is an Array ([author, title]) so ties are broken deterministically. sort_by is not stable, and relying on insertion order of equal keys is a common source of tests that fail on another Ruby version.

For more on the parameter kinds and on ... and anonymous argument forwarding, see the pages linked at the top. A wrapper that only passes arguments along should use the forwarding syntax instead of re-declaring them.

Guard clauses and fail-fast code

A guard clause handles the exceptional case first and leaves the method, so the rest of the body is the happy path at the left margin. RuboCop’s Style/GuardClause suggests it whenever a method wraps its whole body in a conditional, and the Style Guide asks to avoid nested conditionals for flow of control.

The same idea applies to data: check at the boundary and fail fast with a clear error rather than let a bad value travel until it fails somewhere far from its cause. The tools:

  • raise ArgumentError, "message" for a bad argument. Put the offending value in the message, using inspect, so that nil and "" are distinguishable.

  • Hash#fetch when a key must be present. options[:name] returns nil for both "missing" and "present but nil`" (and for `false, as the || default below shows); fetch raises KeyError with the key in the message, or takes a default or a block that states what a missing key means.

  • Kernel#Array to accept "one or many": Array(nil) is [], Array(book) is [book] and an Array stays as it is. Beware that Array converts a Hash into pairs and does not split a String.

  • Kernel#Integer and Kernel#Float for strict parsing, with exception: false when nil on failure is what you want.

  • Hash#dig and Array#dig for nested data; they raise TypeError if an intermediate value cannot be dug into (for example an Integer), and return nil for a missing key.

  • The safe navigation operator &. when nil is a legitimate answer in a chain: config&.fetch(:name). It skips the call and its arguments when the receiver is nil, but it is not a general null guard: false&.upcase raises NoMethodError. Long chains of &. mean the model has too many optional parts; give the object a method that hides them.

# frozen_string_literal: true

require "shelf"

module Shelf
  # Guard clauses reject bad input first, so the main path is not nested.
  module Lending
    module_function

    def due_date(loan_days, from: Time.now)
      unless loan_days.is_a?(Integer) && loan_days.positive?
        raise ArgumentError, "loan_days must be a positive Integer, got #{loan_days.inspect}"
      end

      from + (loan_days * 86_400)
    end

    def titles(books)
      return [] if books.nil?

      Array(books).map(&:title)
    end

    # fetch states what a missing key means instead of letting nil travel on.
    def setting(options, name)
      options.fetch(name) { raise Shelf::Error, "missing setting: #{name}" }
    end
  end
end

Shelf::Lending.due_date(14, from: Time.utc(2026, 1, 1)) # => 2026-01-15 00:00:00 UTC

begin
  Shelf::Lending.due_date(0)
rescue ArgumentError => e
  e.message # => "loan_days must be a positive Integer, got 0"
end

author = Shelf::Author.new("Frank Herbert")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "Dune", author:)
Shelf::Lending.titles(nil) # => []
Shelf::Lending.titles(book) # => ["Dune"]
Shelf::Lending.titles([book, book]) # => ["Dune", "Dune"]

options = { loan_days: 14, reminders: { email: false } }
Shelf::Lending.setting(options, :loan_days) # => 14
options[:renewals] # => nil
options.dig(:reminders, :email) # => false
options.dig(:reminders, :sms)&.upcase # => nil
options[:reminders][:email] || "default" # => "default"
options.fetch(:reminders).fetch(:email, "default") # => false

begin
  Shelf::Lending.setting(options, :renewals)
rescue Shelf::Error => e
  e.message # => "missing setting: renewals"
end

begin
  options.fetch(:renewals)
rescue KeyError => e
  e.message # => "key not found: :renewals"
end

Source: Hash#fetch

Two details in the output: options[:reminders][:email] || "default" yields "default" even though the key holds false (a bug the version with fetch(:email, "default") does not have), and options.dig(:reminders, :sms)&.upcase is nil without raising, because both the missing key and the safe navigation operator return nil.

unless and raise are the usual guard keywords; rescue is for conditions you can recover from, not for validating input. For the exception hierarchy, custom error classes and retry, see Exceptions and Error Handling.

Conditionals and truthiness

Truthiness and short-circuiting

In Ruby only nil and false are falsy. 0, "", [] and {} are truthy, which is why the idiomatic test for "has content" is a predicate (empty?, zero?, nil?, positive?) and not the value itself. && and || return one of their operands, not a boolean, so name || "anonymous" is a default, value && value.upcase is a guard (better written with &.), and list.first || fallback is a lookup with a fallback. !!value converts to a real boolean; the Style Guide allows it for a deliberate reason (a JSON field, the result of a predicate) and discourages it in a condition, where it only adds noise.

and and or are not the readable spelling of && and ||: they bind more loosely than assignment, so value = lookup or "fallback" assigns lookup and then evaluates the fallback for nothing. Use them, if at all, as flow-control keywords (save or raise "failed"); Style/AndOr flags the rest. The precedence table is on Operators and Precedence.

Reading conditions

  • Name a complicated condition. A predicate method (waived?) or an explaining local variable replaces a comment and can be tested separately.

  • Use unless only for a simple negative, and never with else (Style/UnlessElse). unless a && b is hard to read; if !(a && b) or a predicate is clearer.

  • Use the ternary operator for a short value choice, never nested (Style/NestedTernaryOperator) and never across several lines.

  • Prefer case to a chain of elsif on one value; when uses ===, so ranges, classes, regexps and lambdas work as patterns. For structured data, case/in pattern matching (stable since 3.0) says more; see Pattern Matching. A constant cannot be the end of a range in an in pattern (the parser rejects in 1..GRACE_DAYS); use when, or a guard such as in Integer => days if days <= GRACE_DAYS.

  • Let if and case return their value. Assigning the result of the expression replaces assignments in every branch.

  • Modifier if and unless are for short one-line statements (return if done?), not for long conditions.

  • A leading || or && continues the previous line (Ruby 4.0), like a leading . in a method chain. In 3.4 and earlier the parser rejects it, so code that has to run on older Rubies puts the operator at the end of the line.

# frozen_string_literal: true

require "shelf"

module Shelf
  # Conditionals that read like the rule they implement.
  class Fine
    GRACE_DAYS = 3
    CENTS_PER_DAY = 25
    CAP_CENTS = 1_000

    def initialize(days_late:, member_since_years:, damaged: false)
      @days_late = days_late
      @member_since_years = member_since_years
      @damaged = damaged
    end

    # An explaining name for a compound condition.
    def waived?
      within_grace_period? || (loyal_member? && !damaged?)
    end

    # if/else is an expression: the value is the last evaluated expression.
    def cents
      if waived?
        0
      else
        [chargeable_days * CENTS_PER_DAY, CAP_CENTS].min
      end
    end

    # case/when returning a value; ranges match through ===.
    def label
      case @days_late
      when ..0 then "on time"
      when 1..GRACE_DAYS then "grace period"
      when (GRACE_DAYS + 1)..14 then "late"
      else "very late"
      end
    end

    # A leading || continues the previous line (Ruby 4.0).
    def exempt?
      waived?
        || @member_since_years >= 25
    end

    private

    def within_grace_period? = @days_late <= GRACE_DAYS
    def loyal_member? = @member_since_years >= 5
    def damaged? = @damaged
    def chargeable_days = @days_late - GRACE_DAYS
  end
end

Shelf::Fine.new(days_late: 2, member_since_years: 1).cents # => 0
Shelf::Fine.new(days_late: 10, member_since_years: 1).cents # => 175
Shelf::Fine.new(days_late: 10, member_since_years: 8).cents # => 0
Shelf::Fine.new(days_late: 10, member_since_years: 8, damaged: true).cents # => 175
Shelf::Fine.new(days_late: 100, member_since_years: 1).cents # => 1000
Shelf::Fine.new(days_late: 10, member_since_years: 1).label # => "late"
Shelf::Fine.new(days_late: 30, member_since_years: 30, damaged: true).exempt? # => true

# Truthiness: only nil and false are falsy.
[0, "", [], {}].map { it ? "truthy" : "falsy" } # => ["truthy", "truthy", "truthy", "truthy"]
[nil, false].map { it ? "truthy" : "falsy" } # => ["falsy", "falsy"]

# && and || return one of their operands, which makes defaults and guards short.
name = nil
name || "anonymous" # => "anonymous"
name&.upcase # => nil

# or/and bind more loosely than =: they are flow control, not boolean operators.
missing = {}[:key]
wrong = missing or "fallback"
right = missing || "fallback"
puts "wrong=#{wrong.inspect} right=#{right.inspect}" # prints: wrong=nil right="fallback"

The leading || in exempt? is the Ruby 4.0 form listed in the 4.0 NEWS (logical binary operators at the beginning of a line continue the previous line). Run the same method body on 3.4 and the parser reports a syntax error.

Enumerable instead of counting loops

The idiomatic way to walk a collection is to give a block to a method of Enumerable. The method names say what the loop is for, and the temporary state (index, accumulator, result array) lives inside the method.

Intent Idiom

Transform every element

map

Keep elements that match, drop the rest

select (filter), reject

Transform and drop nil in one pass

filter_map

Find the first match

find, find_index

Any, all or none match

any?, all?, none?, one?

Count matches

count { ... }; size for the total

Fold into one value

sum, reduce, min_by, max_by, tally

Split or group

partition, group_by, each_slice, each_cons

Build a Hash

to_h with a block, each_with_object, group_by, tally

Repeat a number of times

n.times, a.upto(b), a.downto(b), range.step(n)

Four rules complete the table.

  • One block parameter, one name. The it implicit parameter (3.4) is the shortest readable form for a one-argument block; _1 (2.7) is the older numbered spelling, and Symbol#to_proc (map(&:title)) is best when the block would only call one method. RuboCop’s Style/ItBlockParameter (pending, so on with NewCops: enable) defaults to allowing it in single-line blocks. In Ruby 3.3 and earlier it is an ordinary method call, so code that still runs there must name the parameter.

  • Do not build an array with each and << when map, select or filter_map is what you mean (RuboCop’s Style/MapIntoArray), and do not mutate the argument you iterate.

  • size is the total, count with a block is the number of matches. Use size (or length) for the whole collection: count with no block walks an Enumerable that is not an Array.

  • for is not an each alias. It does not create a scope, so the loop variable leaks into the enclosing method (Style/For). Use each.

while and until remain the right tool when the algorithm really is a loop with a changing state, such as a numeric iteration to a fixed point. The example compares the same task written both ways, then lists the idioms in use.

# frozen_string_literal: true

require "shelf"

rows = [["The Dispossessed", "Ursula K. Le Guin", "978-0-306-40615-7", 1974],
        ["A Wizard of Earthsea", "Ursula K. Le Guin", "978-0-14-131026-8", 1968],
        ["Dune", "Frank Herbert", "978-0-441-17271-9", 1965],
        ["Neuromancer", "William Gibson", "978-0-553-38256-3", 1984]]
books = rows.map do |title, name, digits, _year|
  Shelf::Book.new(isbn: Shelf::Isbn.new(digits), title:, author: Shelf::Author.new(name))
end
years = rows.to_h { |title, _name, _digits, year| [title, year] }

# C-style: index, accumulator and mutation all visible.
def ursula_titles(books)
  result = []
  i = 0
  while i < books.size
    result << books[i].title.upcase if books[i].author.name.start_with?("Ursula")
    i += 1
  end
  result
end

ursula_titles(books) # => ["THE DISPOSSESSED", "A WIZARD OF EARTHSEA"]

# Enumerable: say what you want. Same result, no counter, no accumulator.
books.select { it.author.name.start_with?("Ursula") }.map { it.title.upcase }
# => ["THE DISPOSSESSED", "A WIZARD OF EARTHSEA"]

# filter_map: select and map in one pass.
books.filter_map { it.title.upcase if it.author.name.start_with?("Ursula") }
# => ["THE DISPOSSESSED", "A WIZARD OF EARTHSEA"]

# Symbol#to_proc for a single message.
books.map(&:title) # => ["The Dispossessed", "A Wizard of Earthsea", "Dune", "Neuromancer"]

books.find { it.title == "Dune" }&.author&.name # => "Frank Herbert"
books.any? { years.fetch(it.title) < 1970 } # => true
books.count { years.fetch(it.title) < 1970 } # => 2
books.group_by { it.author.name }.transform_values(&:size)
# => {"Ursula K. Le Guin" => 2, "Frank Herbert" => 1, "William Gibson" => 1}
books.map { it.author.name }.tally.max_by(&:last) # => ["Ursula K. Le Guin", 2]
books.sum { years.fetch(it.title) } # => 7891
books.partition { years.fetch(it.title) < 1970 }.map { |part| part.map(&:title) }
# => [["A Wizard of Earthsea", "Dune"], ["The Dispossessed", "Neuromancer"]]
books.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |book, by_decade|
  by_decade[years.fetch(book.title) / 10 * 10] << book.title
end
# => {1970 => ["The Dispossessed"], 1960 => ["A Wizard of Earthsea", "Dune"], 1980 => ["Neuromancer"]}
books.each_slice(2).map { |pair| pair.map(&:title) }
# => [["The Dispossessed", "A Wizard of Earthsea"], ["Dune", "Neuromancer"]]

# Numbered counting loops belong to Integer, not to a while loop.
3.times.map { it * 2 } # => [0, 2, 4]
1.upto(3).to_a # => [1, 2, 3]
3.downto(1).to_a # => [3, 2, 1]
(1..10).step(3).to_a # => [1, 4, 7, 10]

# Where an explicit loop is right: the algorithm is the loop.
def collatz_steps(number)
  steps = 0
  until number == 1
    number = number.even? ? number / 2 : (3 * number) + 1
    steps += 1
  end
  steps
end

collatz_steps(6) # => 8

Source: Enumerable

The last method, collatz_steps, is a loop that cannot be written with an iterator without hiding the algorithm. Searching a sorted array is another case, but there Array#bsearch is the idiom. Lazy enumerators, infinite sequences and Enumerator::Lazy are in Enumerators and Lazy Evaluation.

Return values, implicit return and method chains

The value of a method is the value of its last expression, so an explicit return is for leaving early, not for returning at the end (Style/RedundantReturn; the Style Guide says to avoid return where it is not needed for flow of control). Two corollaries:

  • Decide what a method returns, on purpose. A command that changes state returns self when the object is built by chaining, or something meaningful (a count, the new value). A method that ends with puts or if without else returns nil, which is rarely what a reader expects.

  • A setter’s value is the argument, not the return value. obj.x = 5 evaluates to 5 even if the x= method returns something else; only a call through send (or public_send) sees the real return value. Do not rely on a setter’s return value.

Returning self from mutators enables fluent chains (builders, Enumerator::Lazy, Comparable pipelines). Chains that span lines put the dot at the start of the continuation line (Layout/DotPosition default is leading), which makes every line read as a step and lets comments sit between steps. With tap for a peek and then for a pipeline step, a chain avoids temporary variables, but the Style Guide recommends removing tap from production code once it was added for debugging.

# frozen_string_literal: true

require "shelf"

module Shelf
  # The value of a method is its last expression; return is for leaving early.
  class Bookcase
    attr_reader :books

    def initialize = @books = []

    def add(book)
      @books << book
      self
    end

    def count = @books.size

    # Assignment methods always evaluate to the right-hand side, whatever they return.
    def label=(value)
      @label = value.to_s.strip
      :ignored
    end

    def label = @label || "unlabelled"

    def empty_report
      puts "nothing to report" if books.empty?
    end
  end
end

author = Shelf::Author.new("Frank Herbert")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-441-17271-9"), title: "Dune", author:)
shelf = Shelf::Bookcase.new

shelf.add(book).add(book).count # => 2
echoed = shelf.label = "  fiction "
echoed.length # => 10
shelf.label # => "fiction"
shelf.send(:label=, " x ") # => :ignored
shelf.empty_report # => nil
Shelf::Bookcase.new.empty_report # prints: nothing to report, returns nil

titles = shelf.books
              .map(&:title)
              .uniq
              .join(", ")
titles.upcase # => "DUNE"

Getting the indentation of a multi-line chain right is RuboCop’s Layout/MultilineMethodCallIndentation job; the default aligned style, used above, lines the dots up under the first call after the receiver.

Value objects and small classes

Most domain concepts in the shelf gem (an ISBN, a loan period, an author) are values: defined by their contents, interchangeable when equal, and never changed after creation. Ruby’s tool for them is Data.define (3.2); see Struct, Data and Value Objects for the full API. The clean-code points:

  • Define the class with a block assigned to a constant, LoanPeriod = Data.define(:days) do plus the body and end, and do not inherit from the result of Data.define or Struct.new. Inheriting adds an anonymous class to ancestors, which the Style Guide advises against. Pre-3.2 code that subclasses Struct.new(...) or relies on keyword_init: true can be converted to the form shown below.

  • Validate in initialize and call super; every instance that exists is then valid.

  • Include Comparable and define <=> (return nil for an incomparable operand) to get <, >, between? and clamp. Data already supplies ==, eql?, hash, inspect and with.

  • Define to_s for domain objects: it is what string interpolation and puts call. Keep inspect for debugging.

  • Use attr_reader (or Data members) instead of hand-written getters, attr_writer or attr_accessor only where external code really needs to assign, and group them at the top of the class. Do not call the deprecated one-argument attr.

  • Keep construction side-effect free. No file or network access in initialize; offer a factory method (self.from_file) for those.

A fluent builder (every step returns self) is the one place where mutable state with chained setters is idiomatic.

# frozen_string_literal: true

require "shelf"

module Shelf
  # A small immutable value object: equality, ordering and a readable to_s for free.
  LoanPeriod = Data.define(:days) do
    include Comparable

    def initialize(days:)
      raise ArgumentError, "days must be positive, got #{days.inspect}" unless days.is_a?(Integer) && days.positive?

      super
    end

    def <=>(other) = other.is_a?(LoanPeriod) ? days <=> other.days : nil
    def +(other) = with(days: days + other.days)
    def to_s = "#{days} #{days == 1 ? 'day' : 'days'}"
  end

  # A fluent builder: every step returns self, so calls chain.
  class LoanRequest
    def initialize = @attributes = {}

    def for_book(book)
      @attributes[:book] = book
      self
    end

    def for_period(period)
      @attributes[:period] = period
      self
    end

    def build = Loan.new(@attributes.fetch(:book), state: :lent)
  end
end

week = Shelf::LoanPeriod.new(days: 7)
fortnight = Shelf::LoanPeriod.new(days: 14)

week.eql?(Shelf::LoanPeriod.new(days: 7)) # => true
week.between?(week, fortnight) # => true
[week, fortnight].max # => #<data Shelf::LoanPeriod days=14>
[fortnight, week].min # => #<data Shelf::LoanPeriod days=7>
[week, fortnight].reduce(:+) # => #<data Shelf::LoanPeriod days=21>
week.with(days: 1).to_s # => "1 day"
fortnight.clamp(week, week).to_s # => "7 days"
"Loan for #{week}".delete_prefix("Loan for ") # => "7 days"

begin
  week.instance_variable_set(:@days, 1)
rescue FrozenError => e
  e.message # => "can't modify frozen Shelf::LoanPeriod: #<data Shelf::LoanPeriod days=7>"
end

begin
  Shelf::LoanPeriod.new(days: 0)
rescue ArgumentError => e
  e.message # => "days must be positive, got 0"
end

author = Shelf::Author.new("Frank Herbert")
book = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-441-17271-9"), title: "Dune", author:)
loan = Shelf::LoanRequest.new.for_book(book).for_period(week).build
loan.describe # => "Dune is lent"

case book
in { title:, author: { name: } }
  "#{title} / #{name}" # => "Dune / Frank Herbert"
end

Source: Comparable

Notice the one-line pattern in the last case: Data classes expose deconstruct_keys, so in { title:, author: { name: } } binds both values without any code in Book or Author.

Class bodies are easier to read when they follow a stable order: constants, include/extend/prepend, attribute macros (attr_reader), class-level methods, initialize, public instance methods, then protected and private ones. The public methods read from the top as a table of contents, and each private helper appears below the first method that uses it, so the code reads from the general to the specific (the "stepdown" order). Prefer composition (an object that holds and delegates to another) to deep inheritance, and private_class_method :new for objects that should only be created through a factory method. Visibility details are in Visibility and Encapsulation, inheritance and mixins in Classes and Objects and Modules and Mixins.

Small idioms worth knowing

The following idioms are short enough to learn together. The example uses each in context.

  • %w[...] and %i[...] build arrays of strings and symbols; .map(&:to_sym) converts when the source is a string list.

  • ENV.fetch("NAME", default) documents the default of an environment variable. With a block it computes the default lazily or raises.

  • A heredoc with <<~ strips the common leading whitespace; format (alias sprintf) with named references (%<name>s, %<amount>7.2f) keeps text and numbers apart; << on a mutable buffer (+"" or String.new) avoids creating a string per iteration. With # frozen_string_literal: true, a plain "" literal is frozen.

  • ||= memoizes a value that is never nil or false. For values that can be falsy, use defined?(@x) or a sentinel, or compute eagerly.

  • then passes the receiver to a block and returns its result (a pipeline step); tap passes it and returns the receiver (a peek). Both are useful, neither should replace a named local variable that explains a value.

  • Hash.new { |hash, key| ... } provides a computed default; Hash.new([]) shares one array among all keys, which is almost never intended.

  • Comparable#clamp bounds a value (120.clamp(0, 100), or -5.clamp(0..) with an endless range), which is clearer than nested min/max.

# frozen_string_literal: true

require "shelf"

# Small idioms that make Ruby read like the problem.
author = Shelf::Author.new("Ursula K. Le Guin")
isbn = Shelf::Isbn.new("978-0-306-40615-7")
title = "The Dispossessed"

# Hash and keyword shorthand (3.1): name the value once.
Shelf::Book.new(isbn:, title:, author:).to_s # => "The Dispossessed by Ursula K. Le Guin"

# Word and symbol lists.
%w[available lent overdue].map(&:to_sym) # => [:available, :lent, :overdue]
%i[available lent overdue].include?(:lent) # => true

# Configuration with a default: ENV.fetch with a block states the policy.
loan_days = Integer(ENV.fetch("SHELF_LOAN_DAYS", "14"), exception: false) || 14
loan_days.to_s # => "14"

# Building text: a heredoc for prose, format for numbers, << on a mutable buffer in loops.
fine = 1750
report = +"Fines\n"
[["Ana", fine], ["Ben", 0]].each do |name, cents|
  report << format("%-5<name>s %<amount>7.2f\n", name:, amount: cents / 100.0)
end
report.lines.map(&:chomp) # => ["Fines", "Ana     17.50", "Ben      0.00"]

help = <<~TEXT
  Usage: shelf [options]
    -v  print the version
TEXT
help.lines.size # => 2

# Memoization with ||= is fine for values that are never nil or false.
class Report
  def initialize(catalog) = @catalog = catalog

  def titles = @titles ||= @catalog.map(&:title).sort
end

catalog = Shelf::Catalog.new.add(Shelf::Book.new(isbn:, title:, author:))
reports = Report.new(catalog)
reports.titles.equal?(reports.titles) # => true

# then and tap: a pipeline and a peek, without temporary variables.
"  the dispossessed ".strip.then { |text| text.split.map(&:capitalize).join(" ") } # => "The Dispossessed"
sizes = []
[3, 1, 2].sort.tap { |sorted| sizes << sorted.size }.first # => 1
sizes.first # => 3

# Block-local defaults with Hash.new and a block; tally for counting.
%w[a b a].tally # => {"a" => 2, "b" => 1}
Hash.new { |hash, key| hash[key] = key.to_s * 2 }[:ab] # => "abab"

# Comparable#clamp keeps a value within bounds, which is clearer than min and max.
120.clamp(0, 100) # => 100
-5.clamp(0..) # => 0

Source: Kernel#format

Comments and documentation

Comments explain why, not what. A comment that restates the next line goes stale the first time the code changes. The Style Guide’s advice is to refactor instead of commenting bad code, to keep comments for rationale (a workaround for non-obvious library behaviour, the source of an algorithm, a performance trade-off) and to keep them up to date or delete them. A few conventions are worth following:

  • Magic comments go at the top of the file, one per line: # frozen_string_literal: true in every library and script file. Strings are still chilled in 4.0 without it (a mutation warns only with -W:deprecated), so the comment is not obsolete. See Strings and Program Structure and Lexical Elements.

  • Annotations (TODO, FIXME, OPTIMIZE, HACK, REVIEW) are written as the first word of the comment, in capitals, followed by a colon and a space (# TODO: support renewals); RuboCop’s Style/CommentAnnotation checks the format.

  • Public API comments use RDoc or YARD syntax, so that ri and generated documentation show them. They describe the contract (arguments, return value, errors), not the implementation. See Documentation with RDoc and YARD.

  • Name instead of comment. An explaining local variable or an extracted method with a good name is better than a comment above a block.

  • Delete dead code instead of commenting it out; version control keeps the history.

Overriding to_s and inspect (see the value-object example) is also documentation: a readable to_s makes log lines, error messages and test failures explain themselves.

Refactoring in small steps, with tests

Refactoring is changing the structure of code without changing what it does. The discipline matters more than the techniques:

flowchart LR A["Tests pass (green)"] --> B["One small structural change"] B --> C{"Tests still pass?"} C -- yes --> D["Commit"] D --> A C -- no --> E["Undo the change"] E --> A
  1. Get the code under test first. For code without tests, write characterization tests that pin down what it does today, including the surprising cases. They are not a statement of what the code should do; they are a safety net.

  2. Change one thing at a time: extract a method, rename a variable, replace a condition with a guard clause, replace a hash with a value object. Run the tests after every step. A step that cannot be undone quickly is too big.

  3. Separate structural changes from behaviour changes (and from formatting changes), so a failing test points at one cause.

  4. Rename in two moves for public code. Add the new name, make the old one call it, mark the old one deprecated, and remove it in a later release. Kernel#warn with category: :deprecated and uplevel: reports the caller’s line, and shows only when deprecation warnings are on (ruby -w, -W:deprecated or Warning[:deprecated] = true; Minitest turns them on by itself, which is why the warnings appear in the run below).

  5. Narrow the interface as you go. Methods that are only used inside the class become private; constructors that should not be called directly become private_class_method :new with a factory method.

  6. Let the tools help, with care. rubocop -a applies only the corrections RuboCop considers safe; rubocop -A also applies unsafe ones that can change behaviour. Run the tests between them, and commit before a large automatic fix.

The following example is a small, realistic refactoring. A legacy calculator works but is hard to read.

# frozen_string_literal: true

module Shelf
  # Works, but nobody dares to touch it.
  class FineCalculator
    def self.calc(days, yrs, dmg)
      fine = 0
      if days > 3
        if yrs >= 5 && !dmg
          fine = 0
        else
          fine = (days - 3) * 25
          fine = 1000 if fine > 1000
        end
      end
      fine
    end
  end
end

Its characterization tests (Minitest 6, the gem version installed here) cover the grace period, the daily rate, the cap and the loyal-member waiver. Two further tests describe the refactoring target: a keyword-based call and a private new. They are written first, so they fail first.

# frozen_string_literal: true

require "minitest/autorun"

# The first four tests pin down what the code does today (characterization tests).
class FineCalculatorTest < Minitest::Test
  def test_no_fine_inside_the_grace_period
    assert_equal 0, Shelf::FineCalculator.calc(3, 1, false)
  end

  def test_charges_per_day_after_the_grace_period
    assert_equal 175, Shelf::FineCalculator.calc(10, 1, false)
  end

  def test_fine_is_capped
    assert_equal 1000, Shelf::FineCalculator.calc(100, 1, false)
  end

  def test_loyal_members_are_waived_unless_the_book_is_damaged
    assert_equal 0, Shelf::FineCalculator.calc(10, 8, false)
    assert_equal 175, Shelf::FineCalculator.calc(10, 8, true)
  end

  # Added during the refactoring, for the new keyword API: red until the refactoring is done.
  def test_call_matches_calc
    assert_equal 175, Shelf::FineCalculator.call(days_late: 10, member_since_years: 1)
  end

  def test_new_is_no_longer_public
    assert_raises(NoMethodError) { Shelf::FineCalculator.new(1, 1, false) }
  end
end

Running the test against the legacy class (ruby -w -r./legacy fine_test.rb --seed 1) is the red step: the four characterization tests pass and the two new ones fail.

Run options: --seed 1

# Running:

..FE..

Finished in 0.000325s, 18461.5368 runs/s, 18461.5368 assertions/s.

  1) Failure:
FineCalculatorTest#test_new_is_no_longer_public [fine_test.rb:30]:
[NoMethodError] exception expected, not
Class: <ArgumentError>
Message: <"wrong number of arguments (given 3, expected 0)">
---Backtrace---
fine_test.rb:30:in 'BasicObject#initialize'
fine_test.rb:30:in 'block in FineCalculatorTest#test_new_is_no_longer_public'
---------------

  2) Error:
FineCalculatorTest#test_call_matches_calc:
NoMethodError: undefined method 'call' for class Shelf::FineCalculator
    fine_test.rb:26:in 'FineCalculatorTest#test_call_matches_calc'

6 runs, 6 assertions, 1 failures, 1 errors, 0 skips

The refactored class keeps calc for existing callers, delegates it to the new call, and reports the deprecation. The logic is split into two private methods with names from the domain.

# frozen_string_literal: true

module Shelf
  # The same behaviour, split into named steps, with a keyword API.
  class FineCalculator
    GRACE_DAYS = 3
    CENTS_PER_DAY = 25
    CAP_CENTS = 1_000
    LOYAL_YEARS = 5

    def self.call(days_late:, member_since_years:, damaged: false)
      new(days_late, member_since_years, damaged).cents
    end

    # The old entry point stays for one release and says so.
    def self.calc(days, years, damaged)
      warn "FineCalculator.calc is deprecated, use FineCalculator.call", category: :deprecated, uplevel: 1
      call(days_late: days, member_since_years: years, damaged:)
    end

    private_class_method :new

    def initialize(days_late, member_since_years, damaged)
      @days_late = days_late
      @member_since_years = member_since_years
      @damaged = damaged
    end

    def cents
      return 0 if waived?

      [chargeable_days * CENTS_PER_DAY, CAP_CENTS].min
    end

    private

    def waived? = chargeable_days <= 0 || (@member_since_years >= LOYAL_YEARS && !@damaged)

    def chargeable_days = @days_late - GRACE_DAYS
  end
end

Source: Kernel#warn

The same test file, unchanged, now passes (ruby -w -r./refactored fine_test.rb --seed 1). The deprecation warnings are the output of the warn call in calc, each pointing at the line in the test that still uses the old name:

fine_test.rb:16: warning: FineCalculator.calc is deprecated, use FineCalculator.call
Run options: --seed 1

# Running:

.fine_test.rb:12: warning: FineCalculator.calc is deprecated, use FineCalculator.call
...fine_test.rb:20: warning: FineCalculator.calc is deprecated, use FineCalculator.call
fine_test.rb:21: warning: FineCalculator.calc is deprecated, use FineCalculator.call
.fine_test.rb:8: warning: FineCalculator.calc is deprecated, use FineCalculator.call
.

Finished in 0.000356s, 16853.9310 runs/s, 19662.9195 assertions/s.

6 runs, 7 assertions, 0 failures, 0 errors, 0 skips

Source: Kernel#warn

The next steps would be to migrate the callers of calc to call one at a time, then delete calc in the next release and its tests. Each is one small, reversible change.

Spotting what to change

RuboCop finds many of the mechanical smells for you. The file below is deliberately bad (it is valid Ruby, and ruby -w is silent about it) and shows which cops flag the usual problems. The output is RuboCop 1.92’s emacs format with the file name removed.

# frozen_string_literal: true

class Reservation
  def is_active?(status)
    if status != nil
      if status == :lent
        return true
      else
        return false
      end
    end
  end

  def titles(books)
    result = []
    for book in books
      result << book.title
    end
    result
  end

  def notice(overdue)
    unless overdue
      "ok"
    else
      "late"
    end
  end

  def present?(value) = !!value
end
4:7: C: Naming/PredicatePrefix: Rename `is_active?` to `active?`.
5:5: C: [Correctable] Style/GuardClause: Use a guard clause (`return unless status != nil`) instead of wrapping the code inside a conditional expression.
5:8: C: [Correctable] Style/NonNilCheck: Prefer `!status.nil?` over `status != nil`.
6:7: C: [Correctable] Style/GuardClause: Use a guard clause (`return true if status == :lent`) instead of wrapping the code inside a conditional expression.
7:9: C: [Correctable] Style/RedundantReturn: Redundant `return` detected.
9:9: C: [Correctable] Style/RedundantReturn: Redundant `return` detected.
16:5: C: [Correctable] Style/For: Prefer `each` over `for`.
23:5: C: [Correctable] Style/UnlessElse: Do not use `unless` with `else`. Rewrite these with the positive case first.

Not every smell is mechanical. Look also for: a class that needs "and" in its description; a method whose parameters are passed unchanged to three other methods (a missing parameter object); the same case on a type in several places (a missing polymorphic method, see SOLID and Design Patterns); a comment that apologises; and a test that needs to know the internals of the class it covers. reek, flay and flog look for such patterns; they are community gems and are described on Style, Linting and Static Analysis.

Where older code and books differ

Books and tutorials on idiomatic Ruby age. This table lists what to translate when you meet them; the "current" column is what the examples on this page use.

Topic Older advice or output Ruby 4.0

Block parameters

Numbered parameters _1 only, or a named parameter for every block

it (3.4) in short blocks; &:title for one call

Frozen string literals

"String literals are frozen by default since 3.4"

Chilled strings (3.4): mutation warns only with -W:deprecated. They are not frozen; the magic comment or --enable-frozen-string-literal is still needed

Error messages

undefined method `x' for nil:NilClass

undefined method 'x' for nil (3.3 receiver format, 3.4 quotes); tests that match the message text must be updated

Hash inspection

{:name=>"Ico"}

{name: "Ico"} (3.4); the # => comments here use this form

Value objects

class Point < Struct.new(:x, :y), keyword_init: true, OpenStruct

Point = Data.define(:x, :y) (3.2); Struct for mutable records; no inheriting from the generated class

Keyword and option arguments

def f(opts = {}), a trailing hash treated as keywords

Keywords are separate from positional hashes since 3.0; hash and keyword value omission (3.1); anonymous *, ** and & forwarding (3.1-3.2)

Short methods

A multi-line def for every one-expression method

Endless methods (3.0) for single, side-effect-free expressions

Pattern matching

"Introduced in 2.7" (it was experimental there)

Stable since 3.0; find pattern stable in 3.2; pin expressions in 3.1

Abstract methods

raise NotImplementedError in the base class

NotImplementedError is a ScriptError, which a bare rescue does not catch. Raise NoMethodError or a custom error, or leave the method undefined

Predicate and bang folklore

"`?` must return a boolean", "`!` means it mutates"

? is a question with a truthy or falsy answer; ! marks the more dangerous twin of a method that has a safe one

Standard library

benchmark, logger, ostruct, observer, csv described as part of the standard library

They are bundled gems or were moved out of the default set in 3.4 and 4.0; under Bundler they need a Gemfile entry (see the standard library tour)

Dates

DateTime in examples

Time; DateTime is deprecated

Some book advice is also simply wrong and should not be repeated: a ? predicate that "must return a Boolean"; ! that "means mutation"; safe_constantize (an ActiveSupport method, not core Ruby) as a way to turn input into a class, which is a security hazard; and NotImplementedError for abstract methods. These are corrected in the sections above. See Standard Library Tour for the library changes and Versions, Releases and Compatibility for the release schedule.

Guidelines

  • Name for the reader: snake_case methods and variables, CapitalCase classes, SCREAMING_SNAKE_CASE constants, ? for questions, ! only next to a safer twin.

  • Keep methods short and at one level of abstraction; give three or more related parameters a Data class, use keywords from the third parameter and for every flag.

  • Check input at the boundary with guard clauses and ArgumentError; use fetch for keys that must exist, dig and &. for data that may be missing, Array() for one-or-many.

  • Remember that only nil and false are falsy; name complicated conditions, avoid unless with else, never nest ternaries, and prefer case for one value against many.

  • Prefer Enumerable methods (map, select, filter_map, sum, group_by, tally) to counting loops; use it or &:sym for one-argument blocks; keep while for real algorithms.

  • Let the last expression be the return value; return self from chained mutators; never rely on a setter’s return value.

  • Model values with Data, validate in initialize, include Comparable, define to_s, and do not subclass the generated class.

  • Comment the reason, document the public API with RDoc or YARD, and delete commented-out code.

  • Refactor in small steps behind tests, rename public API in two moves with a deprecation, and narrow visibility as you go.

  • Run rubocop and ruby -w in CI; treat the Style Guide as a default, the project’s .rubocop.yml as the decision.