/ Tags: RUBY 3 / Categories: RUBY

filter_map and Modern Enumerable — Ruby's Most Useful Recent Additions

Ruby’s Enumerable module has been quietly gaining methods that make common collection operations more expressive without adding complexity. filter_map alone replaces a pattern that every Ruby developer has written dozens of times. Combined with tally, flat_map, each_cons, each_slice, and zip, these methods cover the gap between raw each loops and overly clever one-liners — the productive middle ground where Ruby code stays readable and the intent is obvious at a glance.

filter_map — Map and Filter in One Pass


Before filter_map (Ruby 2.7+), transforming and filtering a collection required chaining map and compact, or select and map. Both approaches iterate the collection twice and add syntactic noise.

Example:

users = [
  { name: "Ada",   admin: true,  score: 95 },
  { name: "Grace", admin: false, score: nil },
  { name: "Alan",  admin: true,  score: 88 }
]

# Old pattern — two passes, compact to remove nils
users.map { |u| u[:score] if u[:admin] }.compact
# => [95, 88]

# filter_map — one pass, return nil/false to exclude
users.filter_map { |u| u[:score] if u[:admin] }
# => [95, 88]

Example:

# Parsing with transformation — skip invalid, transform valid
strings = ["1", "abc", "3", "", "7", "2x"]

# filter_map: return nil for invalid, value for valid
strings.filter_map { |s| Integer(s, exception: false) }
# => [1, 3, 7]

# Without filter_map:
strings.map { |s| Integer(s, exception: false) }.compact

# Extracting nested values with safe navigation
posts = [
  OpenStruct.new(title: "Ruby", author: OpenStruct.new(name: "Ada")),
  OpenStruct.new(title: "Rails", author: nil),
  OpenStruct.new(title: "Tips", author: OpenStruct.new(name: "Grace"))
]

posts.filter_map { |p| p.author&.name }
# => ["Ada", "Grace"]

The rule: return a truthy value to include it in results, return nil or false to exclude. It replaces map.compact for most cases and select.map when the selection and transformation share the same expression.

tally — Count Occurrences


tally (Ruby 2.7+) converts a collection into a hash counting how often each element appears.

Example:

responses = [:yes, :no, :yes, :yes, :maybe, :no, :yes]

responses.tally
# => { yes: 4, no: 2, maybe: 1 }

# Sort by frequency
responses.tally.sort_by { |_, count| -count }.to_h
# => { yes: 4, no: 2, maybe: 1 }

# Most common element
responses.tally.max_by { |_, count| count }.first
# => :yes

Example:

# Tally after transformation
words = "the quick brown fox jumps over the lazy dog the"
words.split.tally.select { |_, count| count > 1 }
# => { "the" => 3 }

# Character frequency
"mississippi".chars.tally.sort_by { |_, c| -c }.first(3)
# => [["s", 4], ["i", 4], ["p", 2]]

# With tally_by (Ruby 3.1+) — group by block result
[1, 2, 3, 4, 5, 6].tally_by { |n| n.even? ? :even : :odd }
# => { odd: 3, even: 3 }

flat_map — Map Then Flatten One Level


flat_map maps and flattens one level in a single pass — equivalent to .map { }.flatten(1) but more expressive and efficient.

Example:

# Expanding nested associations
users = [
  { name: "Ada",   tags: ["ruby", "math"] },
  { name: "Grace", tags: ["computing", "navy"] },
  { name: "Alan",  tags: ["turing", "math"] }
]

# All tags, deduplicated
users.flat_map { |u| u[:tags] }.uniq
# => ["ruby", "math", "computing", "navy", "turing"]

# vs map then flatten
users.map { |u| u[:tags] }.flatten.uniq  # same result, less clear

Example:

# Generating combinations
suits  = [:hearts, :diamonds, :clubs, :spades]
values = [1, 2, 3]

suits.flat_map { |suit| values.map { |v| [suit, v] } }
# => [[:hearts, 1], [:hearts, 2], [:hearts, 3],
#     [:diamonds, 1], ...]

# Splitting sentences into words
paragraphs = ["Ruby is fun.", "Rails is great."]
paragraphs.flat_map { |p| p.split }
# => ["Ruby", "is", "fun.", "Rails", "is", "great."]

each_slice and each_cons — Windowed Iteration


Both methods iterate over a collection in chunks, but with a critical difference: each_slice produces non-overlapping groups, each_cons produces overlapping windows.

Example:

arr = [1, 2, 3, 4, 5, 6, 7, 8]

# each_slice — non-overlapping chunks of n
arr.each_slice(3).to_a
# => [[1, 2, 3], [4, 5, 6], [7, 8]]  (last chunk can be smaller)

# each_cons — sliding window of n consecutive elements
arr.each_cons(3).to_a
# => [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7], [6, 7, 8]]

Example:

# Batch processing with each_slice — avoid loading all records at once
User.find_each.each_slice(100) do |batch|
  batch.each { |user| process(user) }
end

# Detect trend direction with each_cons — compare consecutive values
prices = [100, 102, 99, 103, 107, 105]
prices.each_cons(2).map { |a, b| b > a ? :up : :down }
# => [:up, :down, :up, :up, :down]

# Moving average with each_cons
prices.each_cons(3).map { |window| window.sum.to_f / window.size }
# => [100.33, 101.33, 103.0, 105.0]

zip — Combining Collections


zip pairs up elements from multiple arrays by index, producing an array of tuples.

Example:

names  = ["Ada", "Grace", "Alan"]
scores = [95, 88, 91]
ranks  = [1, 2, 3]

names.zip(scores)
# => [["Ada", 95], ["Grace", 88], ["Alan", 91]]

names.zip(scores, ranks)
# => [["Ada", 95, 1], ["Grace", 88, 2], ["Alan", 91, 3]]

# Build hashes from paired arrays
Hash[names.zip(scores)]
# => { "Ada" => 95, "Grace" => 88, "Alan" => 91 }

# Or with to_h
names.zip(scores).to_h
# => { "Ada" => 95, "Grace" => 88, "Alan" => 91 }

Example:

# zip with a block — process pairs inline without building intermediate array
headers = [:name, :email, :role]
values  = ["Ada", "[email protected]", :admin]

headers.zip(values) { |key, val| puts "#{key}: #{val}" }
# name: Ada
# email: [email protected]
# role: admin

# Transposing a matrix with zip
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
matrix.first.zip(*matrix[1..])
# => [[1, 4, 7], [2, 5, 8], [3, 6, 9]]

sum With a Block


Enumerable#sum accepts a block to transform before summing — one pass instead of map.sum.

Example:

orders = [
  { quantity: 3, price: 9.99 },
  { quantity: 1, price: 24.50 },
  { quantity: 2, price: 14.00 }
]

# With block — transform and sum in one pass
orders.sum { |o| o[:quantity] * o[:price] }
# => 81.97

# Without block
orders.map { |o| o[:quantity] * o[:price] }.sum

# Initial value for non-numeric types (string concatenation)
["a", "b", "c"].sum("") { |s| s.upcase }
# => "ABC"  (initial value "" required for strings)

Pro-Tip: filter_map is the single method most worth internalizing from this list — not just for the two-pass optimization, but because the pattern it replaces (map.compact, select.map) appears constantly in real codebases. Any time you write .map { ... }.compact, ask whether the block naturally returns nil for cases you want to exclude. If yes, that’s filter_map. The resulting code is shorter and communicates intent — “transform these, skip the ones that don’t apply” — more clearly than the chained version.

Conclusion


filter_map, tally, flat_map, each_slice, each_cons, and zip each solve specific collection problems that otherwise require chaining simpler methods or writing explicit loops. None of them are difficult to understand, and each one has a clear use case that appears regularly in production code. Knowing when to reach for them — rather than defaulting to map + select + compact chains — is the difference between Ruby code that reads like a description of the operation and code that reads like an implementation of it.

FAQs


Q1: When was filter_map introduced?
Ruby 2.7. If you’re on Ruby 2.6 or earlier, use map { }.compact as the equivalent (with the caveat that false values are preserved, unlike filter_map which filters out both nil and false).

Q2: What’s the difference between flat_map and map.flatten?
flat_map flattens exactly one level. map.flatten (without an argument) flattens all levels. If your block returns an array of arrays, flat_map preserves the inner arrays; flatten collapses everything. flat_map is equivalent to map { }.flatten(1).

Q3: Does each_cons include the last incomplete window?
No. each_cons(3) on a 5-element array produces windows [1,2,3], [2,3,4], [3,4,5] — only complete windows. each_slice, by contrast, does include the final partial chunk if the array length isn’t divisible by the slice size.

Q4: Can tally handle custom equality?
tally uses hash equality (eql? and hash). For custom objects, ensure eql? and hash are correctly defined. For case-insensitive string tallying, normalize before calling: words.map(&:downcase).tally.

Q5: Is zip lazy?
zip is not lazy by default — it materializes the full result. For lazy zip-like behavior on infinite sequences, use Enumerator::Lazy with each_with_index or combine lazy enumerators manually. In practice, the inputs to zip are almost always finite arrays where this doesn’t matter.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More