Overloading Binary Operators

How to overload binary operators such as addition, subtraction, multiplication, and division in Rust using std-ops traits with practical examples covering custom types, generics, and reference handling

What Are Binary Operators?

A binary operator takes two values and produces a result. In Rust, +, -, *, /, %, and the bitwise and shift operators are all binary. When you write a + b, the compiler desugars this into a method call: a.add(b). That method comes from a trait in std::ops — each operator has its own trait.

This design means operator overloading is not magic. It is simply implementing a specific trait for your type. The operator then becomes available for any combination of types that have that trait implementation. The compiler checks at compile time whether the required trait is implemented and refuses to build if it is not.

Traits Behind Operators:

Rust’s operator overloading is trait‑based. There is no separate “operator overloading” syntax. You write an impl Add for MyType { ... } and the + operator works. This is the same mechanism used for all other trait‑based behaviour.

The Operator Traits in std::ops

Each binary operator maps to a trait with a fixed signature. All of them live in the std::ops module.

OperatorTraitMethod
+Addfn add(self, rhs: Rhs) -> Output
-Subfn sub(self, rhs: Rhs) -> Output
*Mulfn mul(self, rhs: Rhs) -> Output
/Divfn div(self, rhs: Rhs) -> Output
%Remfn rem(self, rhs: Rhs) -> Output
&BitAndfn bitand(self, rhs: Rhs) -> Output
|BitOrfn bitor(self, rhs: Rhs) -> Output
^BitXorfn bitxor(self, rhs: Rhs) -> Output
<<Shlfn shl(self, rhs: Rhs) -> Output
>>Shrfn shr(self, rhs: Rhs) -> Output

Every trait has a generic parameter Rhs that defaults to Self. The associated type Output defines the type of the result — it can be different from either operand. The method takes self by value, which means the operands are moved unless you implement the trait for references.

Consistent Pattern:

Once you understand Add, you know the pattern for every binary operator. The only differences are the trait name, the method name, and the operator symbol.

Implementing the Add Trait on a Custom Type

The pattern you need to follow for any binary operator looks like this:

use std::ops::Add;
impl Add for MyType {
    type Output = /* result type */;
    fn add(self, rhs: MyType) -> Self::Output {
        // combine self and rhs, return the result
    }
}

After this implementation, my_type_instance + another_instance will compile and call the add method.

Example – Adding Two Points

Imagine a 2D point struct. Adding two points makes sense as component‑wise addition.

use std::ops::Add;
#[derive(Debug, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}
impl Add for Point {
    type Output = Point;
    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

This implementation says: adding two Point values yields a new Point. Because add takes self by value, the original points are consumed. If you try to use p1 after let p3 = p1 + p2;, the compiler will stop you. That ownership rule is not a bug — it is a design choice you can work around with Copy or reference implementations.

Ownership Consumption:

Calling + moves the left‑hand operand. If you need to keep the original values, either derive Copy for the type (if it’s cheap to copy) or implement Add for references. Without one of those two measures, p1 + p2 will invalidate p1 and you’ll get a borrow‑checker error.

Now you can use the operator naturally:

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 5, y: -3 };
    let p3 = p1 + p2;
    println!("{:?}", p3); // Point { x: 6, y: -1 }
    assert_eq!(p3, Point { x: 6, y: -1 });
}

The code adds component‑wise and prints the result. The compiler verifies that Add is implemented for Point, calls p1.add(p2), and stores the Output in p3. The output shows the expected new coordinates.

Overloading Other Binary Operators

Once you have Add working, the other arithmetic operators follow the identical pattern. You can implement them all for the same Point type.

use std::ops::Sub;
impl Sub for Point {
    type Output = Point;
    fn sub(self, other: Point) -> Point {
        Point {
            x: self.x - other.x,
            y: self.y - other.y,
        }
    }
}

Each implementation follows exactly the same structure: specify the trait, set type Output, and write the method body. The operator becomes available immediately.

Division by Zero:

If you implement Div for integer fields, p1 / p2 will panic at runtime if any coordinate of other is zero — just like integer division with built‑in types. Consider using checked_div or returning an Option if you need graceful handling, but that would break the trait’s signature.

Generic Rhs – Adding Different Types

The Rhs type parameter of the trait defaults to Self. You can set it to something else to allow MyType + OtherType. A classic example is adding a Duration to a timestamp, or multiplying a vector by a scalar.

use std::ops::Add;
struct Timestamp {
    seconds: u64,
}
struct Duration {
    seconds: u64,
}
impl Add<Duration> for Timestamp {
    type Output = Timestamp;
    fn add(self, duration: Duration) -> Timestamp {
        Timestamp {
            seconds: self.seconds + duration.seconds,
        }
    }
}

Now timestamp + duration works, but timestamp + timestamp still does not — unless you also implement Add with the default Rhs = Self. This gives you fine‑grained control over which combinations are allowed.

A beginner‑friendly way to think about Rhs is: the type on the right‑hand side of the operator. The left‑hand side is always the type you’re implementing the trait for. Changing Rhs lets you specify that the right operand can be a different type.

Reference‑Based Implementations and the Borrowing Problem

The built‑in numeric types let you add references without explicitly dereferencing: &a + &b works because the standard library implements Add for reference combinations. For your own types, if you only provide impl Add for Point, expressions like &p1 + &p2 will fail with a missing implementation error.

The compiler message often reads: “an implementation of Add<&Point> might be missing for &Point”. To support all combinations of owned and borrowed operands, you typically need four implementations:

  • impl Add for Point (owned + owned)
  • impl Add<&Point> for Point (owned + borrow)
  • impl Add<Point> for &Point (borrow + owned)
  • impl Add<&Point> for &Point (borrow + borrow)

Manually writing all four for each operator is tedious. A common pattern is to write a single value‑based implementation and then forward reference calls to it. The Rust standard library itself uses an internal macro forward_ref_binop. You can replicate that logic:

use std::ops::Add;
impl Add for Point {
    type Output = Point;
    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}
// Forwarding implementations for references
impl<'a> Add<&'a Point> for Point {
    type Output = Point;
    fn add(self, other: &'a Point) -> Point {
        self + *other   // uses the owned + owned impl after deref
    }
}
impl<'a> Add<Point> for &'a Point {
    type Output = Point;
    fn add(self, other: Point) -> Point {
        *self + other
    }
}
impl<'a, 'b> Add<&'b Point> for &'a Point {
    type Output = Point;
    fn add(self, other: &'b Point) -> Point {
        *self + *other
    }
}

Each forwarding implementation dereferences the reference and delegates to the impl Add for Point. This keeps the actual logic in one place.

Don’t Forget All Combinations:

It is easy to implement only impl Add for &Point and then wonder why p1 + p2 doesn’t compile. The compiler sees Point + Point and looks for Add<Point> for Point — it will not automatically coerce. You must provide the exact combination.

Binary Operators with Generic Types

When a struct is generic over T, implementing a binary operator often requires a trait bound on T. For example, a Square<T> whose area is computed by multiplying the side length needs T: Mul<Output = T>.

use std::ops::Mul;
struct Square<T> {
    side: T,
}
impl<T> Square<T>
where
    T: Mul<Output = T> + Copy,
{
    fn area(&self) -> T {
        self.side * self.side
    }
}

The bound T: Mul<Output = T> ensures the multiplication operator exists and yields the same type. The Copy bound avoids moving self.side into the multiplication. This pattern scales naturally to any type that supports multiplication — you might use it with f64, u32, or even a custom number type.

Where Clauses Keep Things Readable:

When constraints grow, a where clause separates the type‑level logic from the function signature. It becomes especially valuable once you combine multiple operator bounds.

Common Mistakes and Pitfalls

Beyond the ownership and reference issues already mentioned, a few other mistakes trip up beginners.

  • Forgetting to set type Output. The trait requires an associated type; if you leave it out, the compiler will complain about an unimplemented associated type.
  • Implementing the wrong combination. You might impl Add<Other> for MyType but then write other + my_type. That requires impl Add<MyType> for Other, not the one you wrote. The operator is not commutative at the type level.
  • Violating the orphan rule. You can implement a trait from std::ops for your own type, but you cannot implement it for a foreign type (like impl Add for Vec<i32>). The compiler will refuse with an orphan rule error.

Orphan Rule:

If neither the trait nor the type is yours, the compiler stops you. For example, impl Add for Vec<i32> is forbidden because both Add and Vec are defined in the standard library, not in your crate.

  • Assuming operators are free. An Add implementation that allocates memory or does heavy computation can surprise callers who expect a cheap operation. While not technically wrong, it can make code harder to reason about.

Summary

Binary operator overloading in Rust is a direct application of the trait system. You pick the corresponding trait from std::ops, specify the output type, and provide the method that performs the operation. The compiler then allows the operator syntax on your types.

The core takeaways:

  • Each binary operator has its own trait; they all follow the same structural pattern.
  • The default Rhs = Self can be overridden to accept a different right‑hand type.
  • Ownership of self means you must either copy small values or implement the trait for references to avoid consuming data.
  • Forwarding reference implementations through a single value‑based impl keeps the code DRY and mirrors what the standard library does.