Chaining Enumerable Methods
Chaining is the practice of calling one Enumerable method right after another, letting the output of one step become the input of the next. Instead of writing a loop with several intermediate variables, you describe a pipeline: filter this, transform that, then total it up. Ruby is built for this style, and once you can read a chain fluently, entire loops disappear into a single, expressive line.
Overview: How Chaining Works
Enumerable is a module mixed into Array, Hash, Range, and any custom class that defines an each method and includes the module itself. Nearly every method you know — map, select, reject, sort_by, reduce, tally, group_by — is defined once in Enumerable and inherited by all of them. This is why an Array and a Range share the exact same vocabulary.
Chaining works because most Enumerable methods follow a simple contract: given a collection, they return a new collection (or a single value), without modifying the original. numbers.select { ... } does not touch numbers; it builds and returns a brand-new array. Because the return value is itself an object that responds to Enumerable methods, you can immediately call another method on it, and another, and another. Each link in the chain hands a fresh object to the next.
There is an important truthiness rule buried inside every filtering method. select and reject decide whether to keep an element based on whether the block’s return value is truthy, and in Ruby only nil and false are falsy. That means numbers.select { |n| n } keeps every number, including 0, because 0 is truthy in Ruby — unlike languages such as JavaScript, Python, or C. Always write an explicit condition (n.positive?, n != 0) rather than relying on a bare value being “falsy” the way it might be elsewhere.
Chains are normally eager: each method fully iterates its input and builds its complete output before the next method runs at all. That is fine for small, finite collections, but it becomes a problem for huge datasets or infinite sequences like (1..Float::INFINITY). Ruby solves this with lazy enumerators. Calling .lazy on a collection returns an Enumerator::Lazy that records the chain of operations without running any of them. Nothing actually executes until a terminal method — first, take, to_a, or force — pulls values through the whole pipeline one element at a time. This is what makes it safe to chain select and map onto an infinite range, something that would hang forever without .lazy.
Syntax
A chain is just a sequence of method calls, each one invoked on the return value of the one before it. When a chain grows past two or three links, the idiomatic style breaks it across lines with the dot leading each new line:
collection
.method_one { |element| }
.method_two { |element| }
.method_three
| Method | Returns | Purpose |
|---|---|---|
map / collect |
Array | Transform every element into something new |
select / filter |
Array | Keep elements where the block is truthy |
reject |
Array | Drop elements where the block is truthy |
sort_by |
Array | Order elements by a computed key |
reduce / inject |
varies | Fold all elements into one accumulated value |
tally |
Hash | Count how many times each element occurs |
group_by |
Hash | Partition elements into buckets by a key |
flat_map |
Array | map followed by flattening one level |
first(n) / take(n) |
Array | Terminal: stop after n elements |
lazy |
Enumerator::Lazy | Defer every step until a terminal call runs |
Examples
Example 1: A basic filter-transform-reduce chain
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
result = numbers
.select { |n| n.even? }
.map { |n| n**2 }
.sum
puts result
Output:
220
Ruby evaluates this strictly left to right. select walks all ten numbers and returns a new array of the even ones: [2, 4, 6, 8, 10]. That array becomes the receiver for map, which squares each value into [4, 16, 36, 64, 100]. Finally sum folds that array down to a single integer, 220, which gets assigned to result.
Example 2: Filtering and shaping a list of records
people = [
{ name: "Ava", age: 28 },
{ name: "Ben", age: 17 },
{ name: "Cleo", age: 34 },
{ name: "Drew", age: 22 }
]
adult_names = people
.select { |person| person[:age] >= 18 }
.sort_by { |person| person[:age] }
.map { |person| person[:name] }
puts adult_names.join(", ")
Output:
Drew, Ava, Cleo
This is the shape most real chains take: filter out records you don’t care about, order what’s left, then pull out just the field you need. select drops Ben (age 17), sort_by arranges the remaining three youngest-first, and the final map reduces each hash down to a plain name string. Each step is a small, obvious idea, and reading top to bottom tells the whole story.
Example 3: Counting and ranking with tally
text = "the quick brown fox jumps over the lazy dog the fox runs the fox the dog"
stop_words = %w[the over]
top_words = text
.split
.map(&:downcase)
.reject { |word| stop_words.include?(word) }
.tally
.sort_by { |_word, count| -count }
.first(2)
top_words.each do |word, count|
puts "#{word}: #{count}"
end
Output:
fox: 3
dog: 2
split breaks the sentence into individual words, reject strips out the stop words, and tally (a core Enumerable method) turns the remaining word list into a Hash of word to occurrence count. sort_by then orders that hash’s pairs by negative count so the most frequent word sorts first, and first(2) keeps only the top two. Notice a chain can freely switch between arrays and hashes — Hash includes Enumerable too, so sort_by works on it exactly the same way it works on an array.
Example 4: Lazy chaining over an infinite range
result = (1..Float::INFINITY).lazy
.select { |n| n.odd? }
.map { |n| n * n }
.first(3)
puts result.inspect
Output:
[1, 9, 25]
(1..Float::INFINITY) never ends, so calling .select or .map on it directly would loop forever trying to build a complete intermediate array. Calling .lazy first changes the behavior entirely: select and map just record what should happen, and nothing runs until first(3) asks for three results. Ruby then pulls values one at a time — test if odd, square it, check the next — stopping the instant it has three answers. This pattern is essential any time your source is huge, unbounded, or expensive to fully materialize (a file being read line by line, for instance).
How It Works Step by Step
Walking through Example 1 again, in order:
- Ruby evaluates
numbers.select { |n| n.even? }completely: it iterates over all ten elements, runs the block for each one, and returns a new array holding only the ones that passed —[2, 4, 6, 8, 10]. The originalnumbersarray is untouched. - That new array is the receiver for
.map { |n| n**2 }. Ruby iterates over its five elements and returns yet another new array,[4, 16, 36, 64, 100]. - That array becomes the receiver for
.sum, which folds all five values together with+and returns a singleInteger,220. resultis assigned220, andputsprints it.
Each step fully finishes — and allocates a whole new array — before the next one begins. That is eager evaluation. Example 4’s .lazy chain replaces this three-pass, three-array process with a single pass that pulls one value at a time through every step before moving to the next source element, which is the whole reason it can work on an infinite sequence at all.
Common Mistakes
Mistake 1: A bang method silently mutates the original collection
It’s tempting to drop a bang method into the middle of a chain without thinking about it, but sort! doesn’t just return a sorted array — it reorders the receiver in place, permanently.
original = [3, 1, 2]
sorted_doubled = original.sort!.map { |n| n * 2 }
puts sorted_doubled.inspect
puts original.inspect
Output:
[2, 4, 6]
[1, 2, 3]
The intent was to compute a new, sorted-and-doubled array, but original itself got permanently reordered as a side effect — a classic bug when original is shared with, or referenced by, other code. Using the non-mutating sort fixes it:
original = [3, 1, 2]
sorted_doubled = original.sort.map { |n| n * 2 }
puts sorted_doubled.inspect
puts original.inspect
Output:
[2, 4, 6]
[3, 1, 2]
original is left exactly as it was, because sort (no !) always returns a brand-new array.
Mistake 2: Chaining each where map was needed
each is built for running side effects, not for transforming data — it always returns its original, unmodified receiver, no matter what the block does.
numbers = [1, 2, 3]
result = numbers.each { |n| n * 10 }.select { |n| n > 5 }
puts result.inspect
Output:
[]
The block’s n * 10 values are computed and immediately thrown away; each hands select the original [1, 2, 3], and none of those are greater than 5, so the result is empty. The fix is to use map, which actually returns the transformed values:
numbers = [1, 2, 3]
result = numbers.map { |n| n * 10 }.select { |n| n > 5 }
puts result.inspect
Output:
[10, 20, 30]
As a rule of thumb: reach for each only at the very end of a chain (or not at all), and use map the moment you need the block’s return values to flow to the next step.
Best Practices
- Once a chain has more than two or three links, format it with one method per line and a leading dot, so it reads top to bottom and diffs cleanly.
- If a chain is doing more than one distinct idea, split it into a well-named intermediate variable or a small private method rather than cramming everything into one expression.
- Know which links in your chain end in
!; a single bang method mutates the original receiver and can silently break code elsewhere that still holds a reference to it. - Temporarily insert
.tap { |x| p x }anywhere in a chain to inspect an intermediate value while debugging, then remove it once you’re done. - Reach for
.lazywhen the source is very large, unbounded (like an endlessRange), or read incrementally from IO, so you never build a full intermediate array you don’t need. - Remember that many Enumerable methods called without a block return an
Enumerator, which is itself chainable — you can compose iteration steps before ever supplying a block. - Prefer
reduceoreach_with_objectover a longmap/selectchain when you’re really building up one accumulated value, since it avoids allocating a fresh array at every step.
Practice Exercises
- Given
numbers = (1..30).to_a, chainselect,map, andsumto find the sum of the cubes of every multiple of 3 between 1 and 30. Hint: the expected result is81675. - Given
words = ["ruby", "elixir", "go", "elixir", "python", "go", "go"], chaintally,sort_by(ormax_by), and a way to pull out just the key, to find the single most frequently occurring word. Hint: the answer is"go". - Using
(1..Float::INFINITY).lazy, build a chain withselect,map, andfirst(5)that finds the first five perfect squares greater than 100. Hint: the result should be[121, 144, 169, 196, 225].
Summary
- Most Enumerable methods return a brand-new collection or value without touching the original, which is exactly what makes chaining them together possible.
- A chain runs left to right, and by default each link fully finishes — building a complete intermediate array — before the next one starts.
- Only
nilandfalseare falsy in Ruby, soselect/rejectkeep values like0,"", and[]unless your condition says otherwise. .lazydefers an entire chain into a single pass that only runs when a terminal method likefirstorto_apulls results — essential for infinite or huge sources.- Bang methods like
sort!mutate the receiver in place; mixing one into a chain changes the original object, not just the chain’s output. eachalways returns its original receiver, never the block’s results — usemapwhenever the next link needs transformed values.- Break long or non-obvious chains across multiple lines, or extract them into a named method, to keep them readable for the next person (often future you).
