How to implement operator overloading

Implement operator overloading in Rust by defining traits from the std::ops module for your custom struct.

When the compiler doesn't know your math

You're building a 2D game engine. You have a Vector2 struct holding x and y coordinates. You want to move a character by adding two vectors together. You write let new_pos = pos + velocity;. The compiler screams. It doesn't know how to add two Vector2 structs. It only knows how to add integers and floats. You need to teach Rust your rules.

Rust treats operators as syntactic sugar for method calls. The + operator is just a shorthand for calling .add(). By default, Rust only knows the translations for built-in types like i32 and f64. When you define a custom type, Rust draws a blank. Operator overloading is the process of filling in that blank. You implement a trait from std::ops, and suddenly the compiler understands your custom syntax. It's not magic. It's just a trait implementation that the compiler looks up when it sees the symbol.

The trait behind the symbol

Every operator in Rust corresponds to a trait in the std::ops module. Addition is Add. Subtraction is Sub. Multiplication is Mul. These traits define the contract for how the operator behaves.

The Add trait has two parts. It requires an associated type called Output that tells the compiler what the result of the addition is. It also requires a method called add that performs the calculation. The associated type is essential because the result of an operation doesn't have to be the same type as the inputs. You might add two i32 values and get an i64 to prevent overflow, or add a Point and a Direction to get a new Point. The Output type gives you that flexibility.

use std::ops::Add;

/// A simple 2D point with integer coordinates.
#[derive(Debug, Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

// Implement the Add trait to enable the + operator.
impl Add for Point {
    // The Output associated type tells the compiler what + returns.
    type Output = Point;

    // The add method defines the logic.
    // It takes self by value, consuming the left operand.
    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    // This calls Point::add(p1, p2) behind the scenes.
    let p3 = p1 + p2;
    println!("Result: {:?}", p3);
}

The compiler translates the symbol into a trait call. Write the trait, and the symbol works.

How the compiler resolves operators

When the compiler sees p1 + p2, it doesn't look for a magic + function. It performs a trait resolution. It asks: "Does Point implement Add? If so, what is the Output type?" It finds the implementation, substitutes the method call, and generates the code.

This resolution happens at compile time. There is no runtime dispatch. The generated code is as fast as writing the addition logic directly. The compiler inlines the call, optimizes the arithmetic, and produces efficient machine code.

The type Output = Point line is crucial. Without it, the compiler doesn't know the result type. This allows a + b to return a different type than the inputs. It also means you can have multiple implementations of Add for the same struct, as long as the generic parameters differ. For example, you can implement Add<i32> for Point to allow adding a scalar to both coordinates.

// Allow adding a scalar to a Point.
impl Add<i32> for Point {
    type Output = Point;

    fn add(self, scalar: i32) -> Point {
        Point {
            x: self.x + scalar,
            y: self.y + scalar,
        }
    }
}

fn main() {
    let p = Point { x: 1, y: 2 };
    let shifted = p + 10; // Calls the Add<i32> implementation.
    println!("{:?}", shifted);
}

This flexibility lets you design APIs that feel natural. You can mix types in expressions as long as you implement the corresponding traits.

The reference dance

In the minimal example, add takes self by value. This means p1 + p2 consumes both points. If Point doesn't implement Copy, the variables p1 and p2 are moved into the function and can't be used afterward. This matches the intuition of arithmetic: 1 + 1 uses the values, it doesn't mutate them.

However, real code often needs to add references to avoid moving data. Rust requires you to implement the trait for every combination you want to support. The compiler doesn't auto-deref operators across trait boundaries like it does for method calls. If you want &p1 + &p2 to work, you have to write the implementation.

The community calls this the "reference dance." For arithmetic types, you typically implement four combinations: owned plus owned, owned plus reference, reference plus owned, and reference plus reference. This makes your type ergonomic. Users can write expressions without cloning everything.

use std::ops::Add;

/// A vector for 2D physics calculations.
#[derive(Debug, Clone, Copy)]
struct Vec2 {
    x: f32,
    y: f32,
}

// Implement Add for owned values.
impl Add for Vec2 {
    type Output = Vec2;

    fn add(self, other: Vec2) -> Vec2 {
        Vec2 {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

// Implement Add for references on the right side.
// This allows v1 + &v2.
impl Add<&Vec2> for Vec2 {
    type Output = Vec2;

    fn add(self, other: &Vec2) -> Vec2 {
        Vec2 {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

// Implement Add for references on both sides.
// This allows &v1 + &v2.
impl Add<&Vec2> for &Vec2 {
    type Output = Vec2;

    fn add(self, other: &Vec2) -> Vec2 {
        Vec2 {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    let v1 = Vec2 { x: 1.0, y: 2.0 };
    let v2 = Vec2 { x: 3.0, y: 4.0 };

    // All of these compile because we implemented the combinations.
    let r1 = v1 + v2;
    let r2 = v1 + &v2;
    let r3 = &v1 + &v2;

    println!("{:?}", r3);
}

Implement the reference combinations. Your users will thank you when they don't have to clone everything just to add two vectors.

Pitfalls and compiler errors

If you forget the type Output line, the compiler rejects you with a "missing field Output" error. The trait requires it. You can't omit it.

If you implement Add for Point but try to write &p1 + &p2, the compiler rejects you with E0277 (the trait bound Add<&Point> for &Point is not satisfied). Rust doesn't guess. It looks for an exact match. The error message tells you exactly which combination is missing. Fix it by implementing the trait for the reference types.

You can't change operator precedence. * always binds tighter than +. If your custom type needs different precedence, operator overloading is the wrong tool. Use a method instead. The precedence is hardcoded in the compiler grammar. You can't override it with a trait.

Another common trap is returning Result from add. You can do it, but it forces the caller to handle errors immediately. The expression a + b becomes a + b? or requires a match. This breaks the flow of arithmetic expressions. The convention is to keep operators pure and side-effect-free. If your addition can fail, use a method like .try_add() that returns a Result.

Rust doesn't guess. If you want &T + &T, you have to write it. Trust the error messages; they tell you exactly which combination is missing.

In-place mutation with AddAssign

Sometimes you want to mutate the left operand instead of creating a new value. The += operator corresponds to the AddAssign trait. This is useful for performance-critical code where you want to avoid allocations or copies.

AddAssign takes &mut self and other by value or reference. It returns (). The mutation happens in place.

use std::ops::AddAssign;

impl AddAssign for Vec2 {
    fn add_assign(&mut self, other: Vec2) {
        self.x += other.x;
        self.y += other.y;
    }
}

fn main() {
    let mut v = Vec2 { x: 1.0, y: 2.0 };
    let delta = Vec2 { x: 3.0, y: 4.0 };
    v += delta; // Mutates v in place.
    println!("{:?}", v);
}

The community convention is to implement AddAssign alongside Add for numeric types. It gives users the choice between creating a new value or mutating in place. If your type is large and doesn't implement Copy, AddAssign can save significant allocation overhead in loops.

Decision: when to use operators

Use std::ops::Add when your type represents a value where addition is a natural, side-effect-free operation that produces a new value. Use std::ops::AddAssign when you need in-place mutation with += syntax and the left operand can be modified. Use a named method when the operation returns a Result or Option, because forcing error handling through + makes the call site noisy and hard to read. Use a named method when the operation has side effects, like logging or network calls, because operators should be pure calculations. Use std::ops::Mul only when the operation truly resembles multiplication; avoid overloading * for string repetition or other domain-specific actions that confuse readers expecting math.

Operators are for math-like purity. If it feels like a hack, it is. Reach for a method.

Where to go next