Back to Blog
C#

C# Operator Overloading: Syntax and Usage

C# operator overloading lets you define how operators work for custom types. This guide covers overloadable operators, syntax rules, conversion operators, common pitfalls, and when operator overloading is the right design choice.

operator overloadingC# language featurescustom typesoperator methodstype design
Illustration of overlapping operator symbols forming an abstract representation of operator overloading in C#

When you define a custom type in C#, the +, ==, or < operators do not automatically work on it. The compiler treats those symbols as invalid for your type unless you implement the appropriate operator overload. C# operator overloading lets you define how operators behave for your own types, which can make code read more naturally than calling explicit methods.

Operator overloading is a compile-time mechanism. When the compiler sees a + b where a and b are of your custom type, it looks for a static method with the operator keyword that matches the signature. The method must be public static. The parameters define the operand types, and the return type defines the result type. For binary operators, the method takes two parameters; for unary operators, it takes one.

Which Operators Are Overloadable

Not every operator in C# can be overloaded. The language defines a fixed set of overloadable operators. For example, +, -, *, /, %, ==, !=, <, >, <=, >=, &, |, ^, <<, >>, !, ~, ++, --, and true/false are overloadable. The conditional logical operators && and || cannot be overloaded directly, but they are evaluated using the overloadable &, |, true, and false operators. Assignment operators like += and -= cannot be overloaded, but they are syntactic sugar: a += b compiles as a = a + b, so if + is overloaded, += works.

Some operators have restrictions not immediately obvious from the syntax. Indexers are declared with this[...], not as operator overloads. Conversion syntax such as (T)x is implemented with explicit or implicit conversion operators, which still use the operator keyword but follow different rules. The is, as, typeof, and new operators are not overloadable. Also, ?? and ?. are not overloadable.

The true and false operators are rarely overloaded, but they enable nullable-like behavior for custom types and allow short-circuiting with && and || when combined with & and |. Overloading true and false requires both to be present, and the return type must be bool.

Basic Operator Overload Syntax

Consider a simple Temperature struct that stores a value in Celsius. A Matrix type with element-wise + is a better domain example, but the following Temperature keeps the syntax small.

public readonly struct Temperature { private readonly double _celsius; public Temperature(double celsius) { _celsius = celsius; } public static Temperature operator +(Temperature left, Temperature right) { return new Temperature(left._celsius + right._celsius); } public override string ToString() => $"{_celsius} °C"; }

Each operator overload is a static method that uses the operator keyword. The method name is the operator symbol itself, not a descriptive name. The parameter list defines the operand types, and the return type defines the result type. In this example, adding two temperatures returns a new temperature. The operator method is called when you write temp1 + temp2.

This approach works because the operator method is public static. At least one parameter of a binary operator must be the enclosing type; for a unary operator, the single parameter must be the enclosing type. The compiler enforces these rules to ensure the operator belongs to the type.

Overloading Comparison Operators

Comparison operators require pairs. Overloading == forces you to also overload !=, and overloading < forces you to also overload >. The same applies to <= and >=. The return type for all comparison operators must be bool. This is different from, say, the + operator, which can return any type.

When you overload ==, the compiler expects you to also override Equals and GetHashCode consistently. The default Equals behavior for value types performs reflection-based field comparison, which is slow compared to a hand-written implementation. Overloading == is usually part of a larger value equality implementation.

public readonly struct Money { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { Amount = amount; Currency = currency ?? throw new ArgumentNullException(nameof(currency)); } public static bool operator ==(Money left, Money right) { return left.Currency == right.Currency && left.Amount == right.Amount; } public static bool operator !=(Money left, Money right) => !(left == right); public override bool Equals(object? obj) => obj is Money other && this == other; public override int GetHashCode() => HashCode.Combine(Amount, Currency); }

If Equals and GetHashCode are not overridden, collections that rely on the default implementations will not call your == operator, so equality behavior can differ between == and containers such as Dictionary and HashSet. Keep ==, Equals, and GetHashCode consistent.

Unary Operators and Symmetry

Unary operators take a single parameter of the enclosing type. The ++ and -- operators can be overloaded; you define the increment or decrement logic, and the compiler supplies the prefix and postfix behavior.

public readonly struct Counter { private readonly int _value; public Counter(int value) => _value = value; public static Counter operator ++(Counter c) => new Counter(c._value + 1); public static Counter operator --(Counter c) => new Counter(c._value - 1); public override string ToString() => _value.ToString(); }

When you write c++ or ++c, the compiler uses your operator ++ method. Because Counter is immutable, the operator returns a new instance. Mutable types could modify their own state and return the same instance, but that pattern is not recommended for value types because the operand is often a copy and the side effects can be surprising.

Not all operators have well-defined math semantics. Overloading + on a Matrix type is natural, but overloading ++ on a Temperature might be confusing. Overload operators only when the mathematical meaning is obvious and consistent with the type's domain.

Implicit and Explicit Conversion Operators

Conversion operators allow your type to be converted to or from another type. They are declared using implicit or explicit with the operator keyword. The target type is the return type, and the source type is the single parameter. One of these types must be the enclosing type.

public readonly struct Fahrenheit { private readonly double _value; public Fahrenheit(double value) => _value = value; public static implicit operator Fahrenheit(Celsius c) => new Fahrenheit(c.CelsiusValue * 9.0 / 5.0 + 32.0); public double FahrenheitValue => _value; } public readonly struct Celsius { public double CelsiusValue { get; } public Celsius(double value) => CelsiusValue = value; public static explicit operator Celsius(Fahrenheit f) => new Celsius((f.FahrenheitValue - 32.0) * 5.0 / 9.0); }

An implicit conversion means the compiler allows the conversion without a cast when no data loss is expected. An explicit conversion requires a cast because the conversion could lose precision or fail at runtime. Here, converting a Fahrenheit to Celsius is explicit; the arithmetic can introduce floating-point rounding, and the conversion is not a guaranteed lossless round trip. Overusing implicit conversions can hide errors, so reserve them for conversions that are always safe and lossless.

The compiler does not chain conversion operators automatically. If you define an implicit conversion from A to B and from B to C, the compiler will not infer an A to C conversion. A single user-defined conversion may include standard conversions before and after it, but you must declare each user-defined conversion step explicitly.

Runtime Behavior and Performance

Operator overloads are resolved at compile time based on the static types of the operands. If you have a Matrix class and a DerivedMatrix class, and you call matrixA + matrixB where both variables are of type Matrix, the compiler binds to the Matrix operator method, even if DerivedMatrix defines a different operator. There is no virtual dispatch on operators. If you need polymorphic behavior, define a virtual method inside your type and have the operator call that method.

Operator methods are static, so they have no virtual call overhead. Like other static methods, they can be inlined by the JIT when the implementation is simple.

If you overload == for a reference type, the compiler will use that overload when both operands are that type. If you compare a reference type to null, the operator overload is still used; if it does not handle null operands, you can get a NullReferenceException inside the operator method. It is good practice to make your comparison operators handle null explicitly, matching the behavior of object.Equals.

Common Pitfalls in Operator Overloading

One frequent mistake is not matching GetHashCode with ==. When two instances are equal according to ==, they must produce the same hash code. If they do not, hash-based collections like Dictionary and HashSet will not behave correctly, because the collection may use GetHashCode to find the bucket and only call Equals when two items land in the same bucket. Even if Equals returns true, the collection might never call it if the hash codes differ.

Another pitfall is overloading operators that are not semantically meaningful for your type. For example, overloading + on a Customer type to concatenate first and last names is legal, but it creates code that is harder to read and prone to misuse. A method GetFullName() is clearer. Overload operators only when the operation is widely understood and expected by other developers reading the code.

Operators on mutable types can cause subtle issues. If the operator modifies the object, then a + b changes a instead of producing a new value. This behavior is especially surprising when the same expression is evaluated multiple times. Prefer immutable types or operator methods that return new instances to avoid these side effects.

Another subtlety is the interaction with Equals. If you overload ==, the compiler warns if Equals is not overridden. Equals is virtual, so object.Equals(obj1, obj2) still dispatches to your override. The == operator, by contrast, is not virtual. If you compare two variables declared as object, the compiler uses reference equality. Boxing a value type and comparing the boxes with == therefore compares references, not values.

When Operator Overloading Is the Right Choice

Use operator overloading when the type represents a numeric-like value where operators carry a strong semantic match. Complex numbers, vector types, matrix operations, points, and monetary amounts with defined arithmetic are good candidates. The + operator on a Vector3 is natural and shortens code. Similarly, == is natural for value equality, and < and > are natural for ordered types.

Avoid operator overloading when the meaning is ambiguous or when a named method would communicate the intent more clearly. For example, a Person type might have a + operator that merges two persons—but that is a domain-specific operation, and a Merge method is far clearer. Also, do not overload operators for types where the operation is invalid, such as dividing a Temperature by another Temperature, unless you have a well-defined domain meaning.

If you decide to overload == and !=, you must also override Equals and GetHashCode. The C# compiler emits a warning if you define == or != without also overriding Equals and GetHashCode. The warning is worth resolving; ignoring it can lead to Dictionary and HashSet misbehavior that is hard to trace.

Accessibility and Inheritance Constraints

Operator overload methods must be declared public static. The containing type must be the type where the operator is defined, and at least one parameter of the operator must be of that containing type. This rule prevents one type from hijacking operators on unrelated types. For example, you cannot define an operator that adds a string and an int without owning either string or int.

Inheritance does not automatically inherit operator overloads in the way virtual methods are overridden. If you define a MatrixBase with operator +, and a DerivedMatrix inherits it, the operator method is still defined on MatrixBase. When you write derived1 + derived2, the compiler resolves the operator using the static types of the operands. If both are DerivedMatrix, the operator method on MatrixBase is called, and both parameters are implicitly converted to MatrixBase (a standard reference conversion). The result type is MatrixBase, not DerivedMatrix, which means you lose the derived type information. To preserve the derived type in the result, you must redefine the operator in the derived class and use the derived type as both the parameters and the return type.

operator methods cannot be virtual, abstract, or override. If you need different behavior in derived types, define a protected virtual method in the base class and have the base operator call it. In the derived class, override the method and optionally redefine the operator with the derived types.

Overloading true and false for Custom Logic

The true and false operators are a special pair that can be overloaded to define a custom truthiness check. They can also be used with && and || if you also overload & and |. The rules are strict: you must overload both true and false, and both must return bool. The & operator must return the enclosing type, and it must take two parameters of the enclosing type. Similarly, | must return the enclosing type.

Consider a Maybe type that represents an optional value. Overloading true to return whether a value exists allows you to write if (maybe) instead of checking a property. However, the true operator is used by the compiler in contexts like if, while, and do statements, but also inside && and || expressions. If you overload & and | to perform short-circuiting behavior, the compiler will use them when the user writes && or ||.

The exact semantics are intricate. The compiler rewrites a && b to T.false(a) ? a : T.&(a, b) for a type T that has a false operator. This means you need a clear design for what a false value means. This feature is rarely needed for domain types and adds significant complexity. Only consider it when you are building a library that emulates nullable logic or flow analysis.

Maintainability and Code Review Concerns

Operator overloading can obscure the underlying behavior, so use it sparingly and document the semantics clearly. In code review, a reviewer should be able to infer the meaning of a + b without reading dozens of lines of implementation. If the operator's behavior is non-standard, add XML documentation comments that explain the operation and any exceptions it can throw.

A practical approach is to make operator overloads thin wrappers around named methods. For example, define Add and Equals methods as the primary implementation, and have the operators call them. This keeps the logic in one place and makes the operator overload trivially consistent with the named methods.

public readonly struct Complex { public double Real { get; } public double Imaginary { get; } public Complex(double real, double imaginary) { Real = real; Imaginary = imaginary; } public Complex Add(Complex other) => new Complex(Real + other.Real, Imaginary + other.Imaginary); public static Complex operator +(Complex left, Complex right) => left.Add(right); public override bool Equals(object? obj) => obj is Complex other && Real == other.Real && Imaginary == other.Imaginary; public override int GetHashCode() => HashCode.Combine(Real, Imaginary); }

This pattern reduces the risk of logic drift between the operator and the named method, and it gives callers a method that is easy to find when they do not want to use the operator syntax.

Compatibility and Interop Notes

Operator overloads are static methods, so generic code cannot use + on an unconstrained type parameter. In older C# versions, this meant workarounds such as expression trees. Newer C# versions (C# 11 and later) support static abstract interface members, so you can constrain a type parameter to an interface that declares an operator, such as IAdditionOperators<T, T, T>, and then use + in generic arithmetic code.

Because operator methods are static, they are bound at compile time. If you have a Matrix type in assembly A and another assembly B defines a wrapper that inherits from Matrix, any operator usage inside assembly B will resolve to the operator defined on Matrix (if the operands are Matrix), not on the derived wrapper, unless the wrapper redefines the operator. This behavior can surprise when new assemblies are added later.

The .NET runtime does not dispatch operators virtually, so an operator overload defined in a base class will not be overridden by a derived class, even if the derived class defines the same operator symbol. This is because an operator overload is a static method with a special name, and redefining it in a derived class hides the base method rather than overriding it. Therefore, to achieve polymorphic behavior, use the virtual method pattern described earlier.

c# operator overloading: Practical Usage and Code Examples | RYUSLOG DEV