Symbol#to_proc and Callable Objects in Ruby — Making Code Do the Talking
Ruby has several ways to represent callable behavior — blocks, procs, lambdas, and method references — and the syntactic sugar that connects them is one of the language’s quieter strengths. The &:symbol shorthand you use daily is actually a specific invocation of a broader protocol. Understanding how it works, and what else the protocol enables, opens up a style of Ruby that’s both more expressive and more composable.
How &:symbol Actually Works
The & operator in a method call converts its argument to a block. It does this by calling to_proc on the object. Symbols have a to_proc method: :upcase.to_proc returns a proc that calls upcase on its first argument.
Example:
# These are equivalent
["hello", "world"].map(&:upcase)
["hello", "world"].map { |str| str.upcase }
# What's happening:
symbol_proc = :upcase.to_proc
# => #<Proc:0x... (&:upcase)>
symbol_proc.call("hello")
# => "HELLO"
This works for any method that takes no additional arguments:
Example:
[1, nil, 2, nil, 3].select(&:present?) # Rails
[1, nil, 2, nil, 3].reject(&:nil?)
[" hello ", "world"].map(&:strip)
[1, -2, 3, -4].select(&:positive?)
When you need arguments, the shorthand doesn’t work — you need a full block or a lambda.
Method Objects — References to Named Methods
method(:name) captures a reference to a named method as a callable object. You can call it directly or convert it to a block.
Example:
# Capturing a method reference
m = method(:puts)
m.call("hello") # => prints "hello"
m.("hello") # same — .() is sugar for .call()
m["hello"] # same
# Instance methods
user = User.new(name: "Alice")
validator = user.method(:valid?)
validator.call # => true or false
Example:
# Converting to a block with &
[1, 2, 3].each(&method(:puts))
# Equivalent to: [1, 2, 3].each { |n| puts n }
# Useful with existing methods
def double(n) = n * 2
[1, 2, 3].map(&method(:double))
# => [2, 4, 6]
This is especially useful when you have a method that’s already defined and you want to use it in a pipeline without wrapping it in a redundant lambda.
Proc Composition with >> and <<
Ruby 2.6 added >> and << for composing procs and lambdas into pipelines.
Example:
double = ->(n) { n * 2 }
increment = ->(n) { n + 1 }
# >> pipes left-to-right (double first, then increment)
double_then_increment = double >> increment
double_then_increment.call(5) # => 11 (5*2=10, 10+1=11)
# << pipes right-to-left (increment first, then double)
increment_then_double = double << increment
increment_then_double.call(5) # => 12 (5+1=6, 6*2=12)
Example:
# Building reusable transformation pipelines
normalize = ->(s) { s.strip.downcase }
slugify = ->(s) { s.gsub(/\s+/, "-").gsub(/[^a-z0-9-]/, "") }
truncate = ->(s) { s[0..49] }
to_slug = normalize >> slugify >> truncate
to_slug.call(" Hello, World! -- Ruby 3.2 ")
# => "hello-world----ruby-32"
Composition works with method references too: method(:normalize) >> method(:slugify).
Curry — Partial Application
curry transforms a multi-argument lambda into a series of single-argument functions, each returning the next function until all arguments are satisfied.
Example:
add = ->(a, b) { a + b }
add5 = add.curry.(5) # partial application — one arg provided, one pending
add5.(3) # => 8
add5.(10) # => 15
# More practical: building specialized functions from a general one
multiply = ->(factor, n) { n * factor }
double = multiply.curry.(2)
triple = multiply.curry.(3)
[1, 2, 3, 4].map(&double) # => [2, 4, 6, 8]
[1, 2, 3, 4].map(&triple) # => [3, 6, 9, 12]
Example:
# Curried validators
in_range = ->(min, max, n) { n.between?(min, max) }
valid_age = in_range.curry.(18).(120)
[15, 25, 200, 45].select { |n| valid_age.(n) }
# => [25, 45]
Currying is valuable when you have a general operation and need several specialized versions of it. It’s not always the clearest choice — a named method or a simple lambda can be more readable. Use it when the pattern genuinely removes duplication.
When to Reach for These Patterns
Use &:symbol freely
It’s idiomatic Ruby and everyone recognizes it. Default to it whenever you’re calling a single no-argument method on each element.
Use &method(:name) when you already have a method
If you’ve already defined a method and want to use it as a block, &method(:name) avoids the wrapper lambda. Particularly useful in pipelines and when the method is doing real work.
Use composition for reusable pipelines
When you find yourself chaining the same sequence of transformations in multiple places, composing them into a named pipeline object is cleaner than duplicating the chain.
Use curry sparingly
Currying has a learning curve. If the person reading the code six months from now would have to stop and think about what it does, a named method or a straightforward lambda is probably clearer.
Pro-Tip:
method(:pp)is surprisingly useful insidetapchains for debugging.array.tap(&method(:pp)).map { |x| transform(x) }prints the array at that point in the chain without interrupting it. When you’re done debugging, delete thetapcall — the rest of the chain is unchanged. It’s cleaner than inserting aputsand abinding.pryand then removing them both.
Conclusion
Ruby’s callable objects — Symbol#to_proc, method references, proc composition, and curry — form a coherent system for building expressive, reusable transformations. The &:symbol shorthand you use every day is the entry point; method objects and proc composition extend the same idea to named methods and multi-step pipelines. None of these require a functional programming background to use well. They’re pragmatic tools for writing Ruby that reads closer to the problem it’s solving than to the implementation details of how it’s solving it.
FAQs
Q1: What’s the difference between a proc and a lambda in Ruby?
Two main differences: lambdas check arity (argument count) strictly and raise ArgumentError if wrong; procs are lenient. Lambdas use return to return from the lambda itself; procs use return to return from the enclosing method. For most callable use cases, use lambdas (->) — the stricter behavior catches bugs earlier.
Q2: Can I use &:method_name with methods that take arguments?
No — &:method_name creates a proc that calls the method with no additional arguments beyond the receiver. For methods with arguments, use a full block or a curried lambda. [1, 2, 3].map(&:+(10)) doesn’t work; use [1, 2, 3].map { |n| n + 10 } or [1, 2, 3].map(&method(:add).curry.(10)).
Q3: Is method(:name) expensive to call?
It allocates a Method object, which is a small allocation. In tight loops called millions of times, this can matter. For most code — pipelines, data transformations, API handlers — the allocation is negligible. Benchmark if you’re unsure; optimize if measurements confirm it’s a bottleneck.
Q4: When should I use >> composition vs just chaining map/select calls?
Chained iterators are usually clearer for single-pass transformations: array.map(&:strip).reject(&:empty?). Proc composition is more useful when the transformation itself is a reusable unit you want to name, pass around, or apply in different contexts. If the pipeline only appears once, keep it as chained calls.
Q5: How does to_proc relate to duck typing?
Any object that responds to to_proc can be converted to a block with &. Symbols, Method objects, and Procs all implement to_proc. You can add to_proc to your own classes to make them usable with & in any method that yields to a block — a useful hook when building DSLs or fluent APIs.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀