Why tap matters

I first stumbled on Object#tap while refactoring a service object that built a complex API payload. The method lets you yield the receiver to a block and then return the receiver itself. That tiny behavior eliminates a whole class of temporary variables and makes the flow of data obvious.

A real‑world scenario

Imagine you’re constructing a JSON payload for a third‑party payment gateway. You need to set a handful of required fields, optionally add metadata, and then log the final hash before sending it. Without tap you’d either mutate a hash in place across several lines or create a chain of merge calls that hide the intermediate state.

def build_payment_payload(order, options = {})
  base = {
    amount: order.total_cents,
    currency: order.currency,
    reference: order.id
  }

  base.tap do |payload|
    payload[:customer_email] = order.customer.email if order.customer&.email
    payload[:metadata] = options[:metadata] if options[:metadata]
    payload[:callback_url] = options[:callback_url] if options[:callback_url]
    Rails.logger.info "Payment payload: #{payload.inspect}"
  end
end

The block receives payload (the same hash referenced by base) and mutates it. After the block finishes, tap returns the hash, so the method returns the fully built payload in a single expression.

Why it works better than the alternatives

  • No intermediate locals – you don’t need a payload = base line just to mutate it.
  • Explicit intent – the tap block signals “I’m configuring this object, then returning it.”
  • Chainable – you can keep calling tap or other methods on the result: build_payment_payload(order).tap { |p| p[:idempotency_key] = SecureRandom.uuid }.

When to reach for tap

Use tap whenever you have an object that needs a few mutations before it’s handed off — configuration hashes, ActiveRecord models before save, or even building a complex query object. It shines in builder‑style code where readability matters more than micro‑optimizations.

Rule of thumb: if you find yourself writing obj = Something.new; obj.configure; obj, replace it with Something.new.tap(&:configure).

Gotchas to watch

tap always returns the receiver, even if the block returns something else. That’s usually what you want, but it can bite you if you accidentally rely on the block’s return value. Also, because the block mutates the original object, be careful when the receiver is shared elsewhere — consider dup.tap { ... } if you need an isolated copy.

Wrapping up

Adding tap to your toolbox reduces boilerplate, makes side‑effects visible, and keeps methods expressive. Next time you’re stitching together a hash or configuring an object before returning it, give tap a try — you’ll notice the code reads more like a narrative than a series of assignments.