Overview
Method chaining connects multiple method calls in a single expression, where each method returns an object that supports the next method in the chain. Ruby's object model makes this pattern natural since every expression returns a value, and methods can return self
or other chainable objects.
The pattern appears throughout Ruby's standard library. String methods like #strip
, #downcase
, and #gsub
return new String objects, enabling chains like " HELLO ".strip.downcase.gsub('l', 'x')
. Array methods such as #select
, #map
, and #sort
return new arrays, supporting functional-style transformations.
# Basic chaining with built-in methods
result = [1, 2, 3, 4, 5]
.select(&:odd?)
.map(&:to_s)
.join(', ')
# => "1, 3, 5"
# String chaining
formatted = " Ruby Programming "
.strip
.downcase
.gsub(/\s+/, '_')
# => "ruby_programming"
Ruby supports two primary chaining approaches: transformation chaining where each method returns a new object of the same or compatible type, and fluent interfaces where methods return self
to enable configuration-style chaining. ActiveRecord queries exemplify transformation chaining, while builder patterns demonstrate fluent interfaces.
The key requirement for chainability is that methods return objects supporting subsequent method calls. This can be the same object (self
), a new object of the same class, or any object with the needed interface.
Basic Usage
String chaining represents the most common form in Ruby. String methods create new objects rather than modifying originals, making every string method call chainable with other string methods.
# String transformation chains
email = " JOHN.DOE@EXAMPLE.COM "
clean_email = email.strip.downcase.gsub(/\./, '_')
# => "john_doe@example_com"
# Complex string processing
text = "The quick brown fox jumps"
result = text
.split(' ')
.map(&:capitalize)
.select { |word| word.length > 3 }
.join(' -> ')
# => "Quick -> Brown -> Jumps"
Array methods provide another fundamental chaining context. Most enumerable methods return arrays or enumerables, supporting functional programming patterns.
# Array processing chains
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
result = numbers
.select(&:even?)
.map { |n| n * 2 }
.sort
.reverse
.first(3)
# => [20, 16, 12]
# Mixed type chains
words = ['apple', 'banana', 'cherry']
lengths = words
.map(&:upcase)
.map(&:length)
.reduce(:+)
# => 17
Custom objects support chaining by returning appropriate values. Methods returning self
enable fluent configuration, while methods returning new objects enable transformation chains.
class StringBuilder
def initialize(content = '')
@content = content
end
def append(text)
@content += text
self
end
def prepend(text)
@content = text + @content
self
end
def upcase
@content.upcase!
self
end
def to_s
@content
end
end
# Fluent interface chaining
result = StringBuilder.new
.append('Hello')
.append(' ')
.append('World')
.prepend('Message: ')
.upcase
.to_s
# => "MESSAGE: HELLO WORLD"
Method chaining works across different object types when methods return objects with compatible interfaces. Hash methods often return arrays, which can then use array methods.
# Cross-type chaining
data = { a: 1, b: 2, c: 3, d: 4 }
result = data
.select { |k, v| v.even? }
.map { |k, v| "#{k}=#{v}" }
.join('&')
# => "b=2&d=4"
Advanced Usage
Complex chaining scenarios emerge when building domain-specific languages, query builders, and configuration APIs. These patterns often combine fluent interfaces with conditional logic and method_missing hooks.
class QueryBuilder
def initialize(table)
@table = table
@conditions = []
@joins = []
@order = []
@limit_value = nil
end
def where(condition)
@conditions << condition
self
end
def join(table)
@joins << table
self
end
def order(column)
@order << column
self
end
def limit(count)
@limit_value = count
self
end
def to_sql
sql = "SELECT * FROM #{@table}"
sql += " JOIN #{@joins.join(' JOIN ')}" unless @joins.empty?
sql += " WHERE #{@conditions.join(' AND ')}" unless @conditions.empty?
sql += " ORDER BY #{@order.join(', ')}" unless @order.empty?
sql += " LIMIT #{@limit_value}" if @limit_value
sql
end
end
# Advanced fluent chaining
query = QueryBuilder.new('users')
.join('profiles')
.where('age > 18')
.where('active = true')
.order('created_at DESC')
.limit(10)
.to_sql
# => "SELECT * FROM users JOIN profiles WHERE age > 18 AND active = true ORDER BY created_at DESC LIMIT 10"
Proc and lambda objects support chaining through composition, creating functional pipelines that transform data through multiple stages.
# Proc chaining for data transformation
validate_email = ->(email) { email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i) }
normalize_email = ->(email) { email.strip.downcase }
extract_domain = ->(email) { email.split('@').last }
# Compose procs into pipeline
email_processor = ->(email) do
normalized = normalize_email.call(email)
return nil unless validate_email.call(normalized)
extract_domain.call(normalized)
end
result = email_processor.call(" JOHN@EXAMPLE.COM ")
# => "example.com"
Method chaining with blocks creates powerful data processing pipelines. Enumerator objects returned by methods like #lazy
enable efficient chaining over large datasets.
# Lazy evaluation chains
def process_large_dataset(items)
items.lazy
.select { |item| expensive_validation(item) }
.map { |item| transform_item(item) }
.reject { |item| item.nil? }
.first(100)
.force
end
# Chaining with custom enumerators
class BatchProcessor
def initialize(items, batch_size = 10)
@items = items
@batch_size = batch_size
end
def each_batch
return enum_for(:each_batch) unless block_given?
@items.each_slice(@batch_size) do |batch|
yield batch
end
end
def process(&block)
each_batch.map(&block)
end
end
# Advanced enumerable chaining
data = (1..100).to_a
results = BatchProcessor.new(data, 5)
.each_batch
.with_index
.map { |batch, index| [index, batch.sum] }
.select { |index, sum| sum > 50 }
# => [[2, 65], [3, 90], [4, 115], ...]
Metaprogramming enables dynamic method chaining through method_missing
and delegation patterns. This creates flexible APIs that respond to arbitrary method calls.
class ChainableHash
def initialize(hash = {})
@data = hash
end
def method_missing(method, *args)
if method.to_s.end_with?('=')
key = method.to_s.chomp('=').to_sym
@data[key] = args.first
self
elsif @data.key?(method)
@data[method]
else
super
end
end
def respond_to_missing?(method, include_private = false)
method.to_s.end_with?('=') || @data.key?(method) || super
end
def to_h
@data
end
end
# Dynamic chaining
config = ChainableHash.new
.host=('localhost')
.port=(3000)
.timeout=(30)
.ssl=(true)
.to_h
# => {:host=>"localhost", :port=>3000, :timeout=>30, :ssl=>true}
Performance & Memory
Method chaining creates intermediate objects that impact memory usage and garbage collection. Each method call in a transformation chain typically allocates new objects, multiplying memory requirements.
# Memory-intensive chaining
large_array = (1..1_000_000).to_a
# Creates multiple intermediate arrays
result = large_array
.select(&:even?) # ~500k elements
.map { |n| n * 2 } # ~500k elements
.select(&:odd?) # 0 elements
.first(10)
# More efficient approach
result = []
large_array.each do |n|
next unless n.even?
doubled = n * 2
next unless doubled.odd?
result << doubled
break if result.size >= 10
end
String chaining creates temporary string objects at each step. For extensive string manipulation, building operations into fewer method calls reduces allocations.
require 'benchmark'
text = "the quick brown fox jumps over the lazy dog" * 1000
# Chain with many intermediate strings
Benchmark.bm do |x|
x.report("chained") do
1000.times do
text.strip.upcase.gsub(/THE/, 'A').gsub(/FOX/, 'CAT').reverse
end
end
x.report("combined") do
1000.times do
text.strip.upcase.gsub(/THE/, 'A').gsub(/FOX/, 'CAT').reverse
end
end
end
Lazy evaluation provides memory-efficient chaining for large datasets. The #lazy
method returns an Enumerator that processes elements on-demand rather than creating intermediate collections.
# Memory-efficient lazy chaining
def process_huge_file(filename)
File.foreach(filename).lazy
.map(&:chomp)
.select { |line| line.start_with?('ERROR') }
.map { |line| parse_error(line) }
.reject(&:nil?)
.first(100)
end
# Benchmark lazy vs eager evaluation
require 'benchmark/memory'
data = (1..1_000_000)
Benchmark.memory do |x|
x.report("eager") do
data.select(&:even?).map(&:to_s).first(10)
end
x.report("lazy") do
data.lazy.select(&:even?).map(&:to_s).first(10).force
end
end
Custom chainable objects should consider return value strategies. Returning self
avoids object creation but requires careful state management, while returning new objects provides immutability at memory cost.
class OptimizedBuilder
def initialize
@operations = []
end
# Return self for configuration
def configure(&block)
instance_eval(&block)
self
end
# Collect operations instead of immediate execution
def transform(operation)
@operations << operation
self
end
# Execute all operations at once
def execute(data)
@operations.reduce(data) { |result, operation|
operation.call(result)
}
end
end
# Deferred execution reduces intermediate objects
builder = OptimizedBuilder.new
.configure { transform(->(x) { x.select(&:even?) }) }
.configure { transform(->(x) { x.map(&:to_s) }) }
result = builder.execute([1, 2, 3, 4, 5])
# => ["2", "4"]
Common Pitfalls
Method chaining can obscure nil values that break chains unexpectedly. Ruby raises NoMethodError when calling methods on nil, but the error location may not indicate where nil originated.
# Fragile chaining
def process_user_email(user)
user.email.strip.downcase.gsub('@', ' at ')
end
# Breaks if user or email is nil
# Better approach with safe navigation
def process_user_email(user)
user&.email&.strip&.downcase&.gsub('@', ' at ')
end
# Or explicit nil handling
def process_user_email(user)
return nil unless user&.email
user.email.strip.downcase.gsub('@', ' at ')
end
Chaining methods with different return types creates confusion. Methods may return objects that don't support expected subsequent methods, leading to runtime errors.
# Type confusion in chains
numbers = [1, 2, 3, 4, 5]
# This breaks - first returns Integer, not Array
result = numbers.select(&:even?).first.map(&:to_s)
# => NoMethodError: undefined method `map' for 2:Integer
# Correct understanding of return types
evens = numbers.select(&:even?) # => [2, 4]
first_even = evens.first # => 2
# Can't call map on Integer
# Fix by understanding what each method returns
result = numbers.select(&:even?).take(1).map(&:to_s)
# => ["2"]
Long chains become difficult to debug because stack traces don't clearly indicate which method failed. Breaking chains or using intermediate variables aids debugging.
# Hard to debug
result = data
.method_a
.method_b
.method_c
.method_d
.method_e
# More debuggable approach
step1 = data.method_a
puts "After method_a: #{step1.inspect}"
step2 = step1.method_b
puts "After method_b: #{step2.inspect}"
step3 = step2.method_c
puts "After method_c: #{step3.inspect}"
result = step3.method_d.method_e
State mutation in fluent interfaces can create surprising behavior when the same object is reused or when methods modify shared state.
class MutableBuilder
def initialize
@items = []
end
def add(item)
@items << item
self
end
def items
@items
end
end
# Shared state problem
builder = MutableBuilder.new
list1 = builder.add('a').add('b').items
list2 = builder.add('c').items
puts list1 # => ['a', 'b', 'c'] (unexpected!)
puts list2 # => ['a', 'b', 'c']
# Fix with immutable returns
class ImmutableBuilder
def initialize(items = [])
@items = items.dup
end
def add(item)
ImmutableBuilder.new(@items + [item])
end
def items
@items.dup
end
end
Performance problems arise from excessive intermediate object creation, especially in loops or with large datasets. Profile code to identify bottlenecks before optimizing.
# Performance trap - creates many intermediate arrays
def inefficient_processing(large_dataset)
large_dataset
.map { |item| expensive_operation_1(item) }
.select { |item| expensive_check(item) }
.map { |item| expensive_operation_2(item) }
.sort
.reverse
.first(10)
end
# More efficient - single pass with early termination
def efficient_processing(large_dataset)
results = []
large_dataset.each do |item|
processed = expensive_operation_1(item)
next unless expensive_check(processed)
final = expensive_operation_2(processed)
results << final
break if results.size >= 10
end
results.sort.reverse
end
Reference
Core Chaining Methods
Method | Returns | Chainable With | Description |
---|---|---|---|
String#strip |
String |
String methods | Removes leading/trailing whitespace |
String#downcase |
String |
String methods | Converts to lowercase |
String#gsub(pattern, replacement) |
String |
String methods | Global string substitution |
String#chomp |
String |
String methods | Removes trailing record separator |
Array#select |
Array |
Enumerable methods | Filters elements by block |
Array#map |
Array |
Enumerable methods | Transforms elements via block |
Array#reject |
Array |
Enumerable methods | Excludes elements matching block |
Array#sort |
Array |
Array methods | Sorts elements |
Array#reverse |
Array |
Array methods | Reverses element order |
Array#uniq |
Array |
Array methods | Removes duplicate elements |
Hash#select |
Hash |
Hash methods | Filters key-value pairs |
Hash#transform_keys |
Hash |
Hash methods | Transforms keys via block |
Hash#transform_values |
Hash |
Hash methods | Transforms values via block |
Enumerable Chain Methods
Method | Parameters | Returns | Description |
---|---|---|---|
#lazy |
None | Enumerator::Lazy |
Enables lazy evaluation |
#with_index(start=0) |
Optional start value | Enumerator |
Adds index to elements |
#each_slice(n) |
Slice size | Enumerator |
Groups elements in slices |
#each_cons(n) |
Window size | Enumerator |
Sliding window iteration |
#zip(*others) |
Other enumerables | Array |
Merges multiple enumerables |
#cycle(n=nil) |
Optional cycle count | Enumerator |
Repeats enumerable |
Safe Navigation
Operator | Usage | Returns | Description |
---|---|---|---|
&. |
object&.method |
Method result or nil |
Calls method only if object not nil |
&.[] |
hash&.[:key] |
Value or nil |
Safe hash/array access |
&.dig |
nested&.dig(:a, :b) |
Value or nil |
Safe nested access |
Fluent Interface Patterns
Pattern | Implementation | Use Case |
---|---|---|
Configuration | Methods return self |
Builder patterns, DSLs |
Transformation | Methods return new objects | Data processing pipelines |
Delegation | Proxy objects with method_missing |
Dynamic APIs |
State Machine | Methods return state objects | Workflow builders |
Performance Characteristics
Pattern | Memory | Speed | Best For |
---|---|---|---|
Eager chaining | High (intermediate objects) | Fast execution | Small datasets |
Lazy chaining | Low (on-demand) | Slower per element | Large datasets |
Mutation chains | Low (reuses objects) | Fastest | When immutability not required |
Functional chains | Medium-High | Medium | When immutability required |
Common Return Types
Starting Type | Common Chains | Final Type |
---|---|---|
String |
#strip.downcase.gsub |
String |
Array |
#select.map.sort |
Array |
Hash |
#select.transform_values |
Hash |
Enumerable |
#lazy.select.first(n) |
Array |
ActiveRecord::Relation |
#where.joins.order |
ActiveRecord::Relation |
Error Handling Strategies
# Defensive chaining
result = object&.method1&.method2 || default_value
# Early return pattern
def process_chain(input)
return nil unless input
step1 = input.process_a
return nil unless step1
step1.process_b.process_c
end
# Exception handling in chains
begin
result = data.chain.of.methods
rescue NoMethodError => e
handle_chain_break(e)
rescue ArgumentError => e
handle_invalid_input(e)
end