skip to content

In Ruby, how does Numeric#coerce let 6 * price work when price is a custom Money object, and what fails without it?

level: seniorimportance: nice to knowfreq 22%

answer

  1. the left operand asks the right
  2. a two-element array comes back
  3. the operator is retried on the pair
  4. can't be coerced into Integer
  5. swapping is safe only when commutative

basics

~10 s

Integer#* does not know Money, so it calls price.coerce(6), expects a two-element array [x, y], and evaluates x * y. Without coerce, Ruby raises TypeError: Money can't be coerced into Integer.

solid answer

~40 s

Numeric operators such as `Integer#+` and `Integer#*` handle numeric operands themselves. For anything else they call `other.coerce(self)` and expect a two-element array `[x, y]`, then evaluate `x.op(y)`. So `6 * price` becomes `price.coerce(6)`, and if `Money#coerce(n)` returns `[self, n]`, Ruby evaluates `price * 6`. Swapping operands is correct only for commutative operators; for `-` or `/` the order changes the result, so there `coerce` should wrap the number or raise `TypeError`. Without `coerce`, Ruby raises `TypeError` with `Money can't be coerced into Integer`, the same mechanism behind `1 + nil` failing with `nil can't be coerced into Integer`. A `coerce` returning anything but a two-element array raises `coerce must return [x, y]`, and a failed comparison such as `6 < price` raises `ArgumentError`.

code

ruby · 21 lines
ruby
class Money
  attr_reader :cents

  def initialize(cents)
    @cents = cents
  end

  def *(factor)
    raise TypeError, "can only multiply Money by an Integer" unless factor.is_a?(Integer)

    Money.new(cents * factor)
  end

  def coerce(number)
    [self, number]   # safe: multiplication is commutative
  end
end

price = Money.new(249)
(price * 6).cents   # => 1494
(6 * price).cents   # => 1494, via price.coerce(6) then price * 6

go deeper

for a junior

Recall that 1 + nil fails with a can't be coerced into Integer TypeError, because the number asks nil to coerce itself.

for a middle

Explain the three steps: the operator calls other.coerce(self), receives [x, y], and re-sends the operator to x with y.

for a senior

Implement coerce on a numeric value object, keep operand order correct for non-commutative operators, and raise TypeError for unsupported types.

for a principal

Judge when a domain type deserves numeric interoperability through coerce, and when explicit conversion at the call site keeps the model clearer.

## The problem: the left operand's method runs In Ruby, `6 * price` is the method call `6.*(price)`. `Integer#*` knows how to multiply by an `Integer`, `Float`, `Rational` or `Complex`, but it has never heard of your `Money` class. You could reopen `Integer` to teach it, but that changes a core class for every library in the process. Ruby provides a cooperative protocol instead: **coerce**. ## How the coerce protocol works When a numeric binary operator meets an operand it does not recognise: 1. It calls `other.coerce(self)`, here `price.coerce(6)`. 2. `coerce` must return a two-element array `[x, y]`. 3. The operator is re-sent as `x.op(y)`, here `x * y`. Core numeric classes use the same protocol among themselves: `2.coerce(3.0)` returns `[3.0, 2.0]`, converting both to a common type. The first element is the new receiver, the second the new argument. ## Implementing coerce on a value class A grocery-order importer multiplies quantities by unit prices, and prices are `Money` objects holding integer cents. `Money#*` accepts a number, so `price * 6` already works. To make `6 * price` work as well: - **Return `[self, n]`**: Ruby then evaluates `price * 6`. This is correct because multiplication is commutative. - **Do not swap for non-commutative operators.** `6 - price` would become `price - 6`, silently reversing the sign. If your class supports such operators with numbers, return `[Money.new(n), self]`, a wrapped number in the original position, so the original order survives. - **Raise `TypeError`** for operand types you do not support, so the caller sees a clear failure. ## Errors you will see | Situation | Result | |---|---| | `6 * price` and `Money` has no `coerce` | `TypeError`: `Money can't be coerced into Integer` | | `1 + nil` | `TypeError`: `nil can't be coerced into Integer` | | `5 + "3"` | `TypeError`: `String can't be coerced into Integer` | | `coerce` returns something other than a two-element array | `TypeError`: `coerce must return [x, y]` | | `6 < price` when coercion fails | `ArgumentError`: `comparison of Integer with Money failed` | The second row is the version most developers meet first: a blank quantity arrives as `nil`, and `subtotal + nil` fails with a message about coercion rather than about nil. ## When coerce is and is not the right tool - **Good fit**: numeric-like value objects such as money, measurements or vectors, where `number op object` has a clear meaning. - **Poor fit**: objects that are not numbers. Giving a `LineItem` a `coerce` method to make `total + item` work hides a conversion that should be explicit, like `total + item.subtotal`. - **Comparisons**: `6 < price` runs the same protocol and evaluates `x < y` on the returned pair, so a swapping `coerce` silently reverses the comparison. Either return the wrapped number in its original position or leave comparisons against bare numbers unsupported. ## Testing a coerce implementation Because `coerce` is called by core code rather than by your own, its bugs show up only in expressions with the number on the left. A short checklist of cases to exercise: 1. `price * 6` and `6 * price` return equal results. 2. `6.0 * price`, an unsupported left operand when only whole quantities are allowed, raises `TypeError` from your `*` after the swap. 3. Every non-commutative operator you support, if any, keeps the original operand order. 4. `6 * Object.new` still raises the standard `can't be coerced` error, proving your class did not change behaviour for anything else. ## Relation to the conversion protocols `coerce` is Ruby's numeric counterpart to implicit conversion methods like `to_str`: it lets core code accept a foreign object without changing core classes. The difference is that `coerce` negotiates a *pair* of operands rather than converting one value, which is why it returns two elements and why the receiver may change.

  • Why is returning [self, number] from coerce wrong for subtraction?
    Ruby evaluates `x - y` with the pair `coerce` returns. For `6 - price`, returning `[self, 6]` computes `price - 6`, reversing the operands and the sign of the result. Return a wrapped number in the original position, `[Money.new(6), self]`, or raise `TypeError` if the operation is meaningless.
  • What does 6 < price raise when Money cannot be coerced?
    Comparison operators use the same protocol, but a failed coercion is reported as `ArgumentError` with `comparison of Integer with Money failed` rather than as `TypeError`. When `coerce` succeeds, Ruby evaluates `x < y` on the returned pair, so a `coerce` that swaps operands turns `6 < price` into `price < 6`.

saying these in an interview costs you the question

  • coerce converts the Money object into an Integer
  • Integer#* calls to_int on unknown operands
  • Returning [self, number] from coerce is correct for every operator
  • The only fix for 6 * price is reopening Integer
  • 1 + nil raises NoMethodError on nil