C# operator overloading: syntax and rules, required pairs, == with Equals/GetHashCode, implicit and explicit conversions, checked operators, generic math, C# 14 compound assignment and design guidelines.
You overload an operator with a public static method named operator + (or ==, <, implicit operator and so on), where at least one parameter is the containing type. Some operators must be overloaded in pairs: ==/!=, </>, <=/>=, true/false. If you overload ==, also override Equals and GetHashCode so all equality paths agree. Conversion operators are implicit (safe, no data loss) or explicit (may fail or lose data). C# 11 added checked operators and static abstract operators in interfaces for generic math (IAdditionOperators<,,>). C# 14 lets you declare compound assignment (+=) and ++/-- as instance operators that update the object in place. Overload only when the meaning is obvious, as with Money, Vector or BigInteger.
Operator overloading lets a type define what operators like +, == or < mean for its instances. It's valuable for value-like types where the operator meaning is obvious (Money + Money, Vector * scalar, Duration < Duration) and harmful anywhere the meaning would surprise. I'd cover the declaration rules, paired operators, equality, conversions, checked operators, generic math, and C# 14's compound assignment operators.
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency) throw new InvalidOperationException("Currency mismatch");
return new(a.Amount + b.Amount, a.Currency);
}
public static Money operator -(Money a) => a with { Amount = -a.Amount }; // unary
public static Money operator *(Money a, decimal factor) => a with { Amount = a.Amount * factor };
public static bool operator <(Money a, Money b) => Compare(a, b) < 0;
public static bool operator >(Money a, Money b) => Compare(a, b) > 0;
private static int Compare(Money a, Money b) { /* currency check */ return a.Amount.CompareTo(b.Amount); }
}
public static methods using the operator keyword.int + int).+ - ! ~ ++ -- true false), binary arithmetic/bitwise/shift (+ - * / % & | ^ << >> >>>), comparison (== != < > <= >=) and conversion operators.&&, || (derived from &/| plus true/false), =, ., ?:, ??, =>, new, is, as, typeof; and compound assignment like += historically was derived from + (until C# 14 — see below).The compiler requires these pairs: ==/!=, </>, <=/>=, and true/false. Defining one without its partner is an error (CS0216).
EqualsIf you overload ==/!=, you must keep them consistent with Equals(object), IEquatable<T>.Equals and GetHashCode (the compiler warns CS0660/CS0661). Typical pattern:
public static bool operator ==(Sku? a, Sku? b) => a?.Equals(b) ?? b is null;
public static bool operator !=(Sku? a, Sku? b) => !(a == b);
Records generate all of these consistently, which is one reason to use them for value types. Overloading == on mutable reference types is usually a mistake — developers expect reference equality from == on classes.
public readonly record struct Abn(string Value)
{
public static explicit operator Abn(string s) => Parse(s); // may fail -> explicit
public static implicit operator string(Abn abn) => abn.Value; // always safe -> implicit
}
Rules: implicit only when the conversion can't fail and loses no information; otherwise explicit. Too many implicit conversions make overload resolution confusing and hide bugs (silently converting an OrderId to a Guid defeats strongly typed IDs).
public static Quantity operator +(Quantity a, Quantity b) => new(unchecked(a.Value + b.Value));
public static Quantity operator checked +(Quantity a, Quantity b) => new(checked(a.Value + b.Value));
In a checked context the checked version is used, so user types can participate in overflow checking like built-in numeric types.
Operators can be static abstract interface members, so generic code can use them:
public readonly record struct Money(decimal Amount) :
IAdditionOperators<Money, Money, Money>, IAdditiveIdentity<Money, Money>
{
public static Money operator +(Money a, Money b) => new(a.Amount + b.Amount);
public static Money AdditiveIdentity => new(0m);
}
static T Sum<T>(IEnumerable<T> items) where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
var total = T.AdditiveIdentity;
foreach (var i in items) total += i;
return total;
}
Previously a += b always compiled to a = a + b — for reference types that means allocating a new object. C# 14 lets types define instance compound assignment operators (and ++/--) that mutate in place:
public sealed class Accumulator
{
private decimal _total;
public void operator +=(decimal value) => _total += value; // in-place, no new object
}
Useful for large mutable types (big-number buffers, matrices, builders) where creating a new instance per operation is wasteful. If not defined, the old a = a + b lowering still applies.
A finance module used decimal everywhere for amounts, and a bug added an AUD amount to a USD amount in a multi-currency report. Introducing a Money record struct with +/- operators that throw on currency mismatch, comparison operators and no implicit conversion from decimal made such mixing a run-time failure in tests rather than a silent wrong total — and the code still read naturally (subtotal + shipping).
Checks rules, consistency and design judgement.
Green flags: public static syntax; required pairs; equality consistency and is null; implicit versus explicit; checked operators; generic math interfaces; C# 14 compound assignment and why it reduces allocations; restraint in choosing when to overload.
Red flags: overloading + on entities; == without GetHashCode; implicit conversions everywhere; unaware that && cannot be overloaded directly.
a + b with a user-defined operator compiles to a static call Money.op_Addition(a, b). Names: op_Addition, op_Subtraction, op_Multiply, op_Equality, op_Implicit, op_Explicit, op_CheckedAddition (C# 11), op_AdditionAssignment (C# 14 compound), etc. Other .NET languages call these methods by name; tools see them in metadata as specialname methods.
The compiler collects candidate operators from both operand types (and built-in operators), applies normal overload resolution, and picks the best. Because it uses static types:
object a = new Money(1, "AUD"), b = new Money(1, "AUD");
a == b; // reference comparison on object - user == NOT used (and for structs, boxes)
This is why == overloads don't help when values are typed as object or in unconstrained generic code — use Equals or EqualityComparer<T>.Default.
User-defined operators on structs are automatically lifted to Nullable<T>: Money? a + Money? b returns null if either is null. Comparison operators lift to return false when either side is null. Keep this in mind for optional amounts.
true/false operators and short-circuitingDefining operator true, operator false and operator &/| allows a type to participate in &&/||: x && y becomes T.false(x) ? x : T.&(x, y). Rarely needed today (historically used for three-valued logic types like SqlBoolean).
Key interfaces in System.Numerics: IAdditionOperators<TSelf, TOther, TResult>, ISubtractionOperators, IMultiplyOperators, IDivisionOperators, IComparisonOperators, IEqualityOperators, IUnaryNegationOperators, IIncrementOperators, identity interfaces (IAdditiveIdentity, IMultiplicativeIdentity), and aggregates like INumber<TSelf>. Implementing only what your type supports is better than claiming INumber<T> (which requires many members that may not make sense, e.g. multiplying money by money).
| Expression | Without user += |
With C# 14 user += (reference type) |
|---|---|---|
a += b |
a = a + b (new instance) |
a.op_AdditionAssignment(b) (mutates) |
a++ |
a = a + 1 via ++ operator returning new value |
Instance operator ++() mutating in place |
For value types, compound operators can also be instance members, mutating the variable in place. Semantic caution: in-place mutation means aliases see changes — appropriate for builder-like types, surprising for value-like types.
Add, Multiply, CompareTo) for languages without operator support and for discoverability (CA2225).==, Equals and GetHashCode agree; prefer records.Money with +, -, comparison operators and currency checks; write tests mixing currencies.Money values typed as object with == and with Equals.IAdditionOperators and IAdditiveIdentity and use a generic Sum<T>.+= operator to a mutable accumulator and confirm no allocations with BenchmarkDotNet.using System.Numerics;
public readonly record struct Money(decimal Amount, string Currency)
: IAdditionOperators<Money, Money, Money>,
IComparisonOperators<Money, Money, bool>
{
public static Money operator +(Money a, Money b) => new(a.Amount + Same(a, b).Amount, a.Currency);
public static Money operator -(Money a, Money b) => new(a.Amount - Same(a, b).Amount, a.Currency);
public static Money operator *(Money a, decimal f) => new(decimal.Round(a.Amount * f, 2), a.Currency);
public static bool operator <(Money a, Money b) => a.Amount < Same(a, b).Amount;
public static bool operator >(Money a, Money b) => a.Amount > Same(a, b).Amount;
public static bool operator <=(Money a, Money b) => a.Amount <= Same(a, b).Amount;
public static bool operator >=(Money a, Money b) => a.Amount >= Same(a, b).Amount;
// == and != are generated by the record struct
public static explicit operator decimal(Money m) => m.Amount; // cast required
private static Money Same(Money a, Money b) =>
a.Currency == b.Currency ? b : throw new InvalidOperationException($"{a.Currency} vs {b.Currency}");
}
// C# 14: in-place compound assignment for a large mutable type
public sealed class Histogram(int buckets)
{
private readonly long[] _counts = new long[buckets];
public static Histogram operator +(Histogram a, Histogram b) // still offered: returns new
{
var r = new Histogram(a._counts.Length);
for (int i = 0; i < r._counts.Length; i++) r._counts[i] = a._counts[i] + b._counts[i];
return r;
}
public void operator +=(Histogram other) // used by h += other
{
for (int i = 0; i < _counts.Length; i++) _counts[i] += other._counts[i];
}
}
var total = new Money(10m, "AUD") + new Money(5m, "AUD"); // 15 AUD
decimal raw = (decimal)total; // explicit conversion
What happens: + on Order adds a line, << writes to a log.
Why it is wrong: readers can't guess the meaning; code becomes cryptic.
Do instead: use named methods unless the meaning is obvious and conventional.
== and EqualsWhat happens: == overloaded but Equals/GetHashCode not (or vice versa).
Why it is wrong: collections and comparisons disagree.
Do instead: implement all consistently or use records.
== on mutable classesWhat happens: value equality operators on entity classes. Why it is wrong: developers expect reference semantics; hash codes change with mutation. Do instead: keep reference equality for mutable classes; compare IDs explicitly.
What happens: implicit operator Guid(OrderId id) and implicit operator OrderId(Guid g).
Why it is wrong: strongly typed IDs silently convert, allowing mix-ups.
Do instead: make conversions explicit (or provide only safe one-way implicit conversions).
What happens: implicit operator Abn(string s) that throws on invalid input.
Why it is wrong: exceptions from innocent-looking assignments.
Do instead: use explicit conversions or Parse/TryParse.
What happens: expecting polymorphic dispatch from operators on base types. Why it is wrong: operators are chosen at compile time from static types. Do instead: delegate operators to virtual methods if polymorphism is needed.
What happens: custom numeric types wrap silently in checked contexts.
Why it is wrong: inconsistent with built-in numeric behaviour.
Do instead: provide checked operators (C# 11) where overflow matters.
What happens: C# 14 += defined to mutate a type used as a value.
Why it is wrong: aliased references see unexpected changes.
Do instead: reserve in-place compound operators for builder/buffer types.
Seniors overload operators to make domain rules impossible to bypass, and only where the meaning is obvious.
| Situation | Senior choice |
|---|---|
| Currency-safe arithmetic | Money value type with checked operators and explicit conversion to decimal |
| Generic numeric algorithms | Implement generic math interfaces |
| Large mutable aggregates in hot loops | C# 14 instance += plus a binary + for immutable use |
| Equality on value objects | Records (generated) rather than hand-written operators |
| Domain entities | No operators; named methods |
They also check overflow intent: provide checked operators for numeric types where overflow must not be silent.
System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
An interface is a contract: a set of members a type promises to provide, with no instance state. A class or struct can implement many interfaces. Since C# 8 an interface can carry default implementations (used only through the interface, and they need .NET Core 3.0+), plus static, private and protected members. Since C# 11 it can declare static abstract / static virtual members, which power generic math (INumber<T>, T.Zero). Explicit implementation (void IDisposable.Dispose()) hides a member unless you cast. Calls through an interface use interface dispatch, which the JIT can often devirtualise. Casting a struct to an interface boxes it; a generic constraint (where T : IComparable<T>) avoids the box.
A record is a type whose identity is its data. public record Money(decimal Amount, string Currency); makes the compiler generate init-only properties, a constructor, Deconstruct, value-based Equals/GetHashCode/==, a readable ToString, and a hidden clone method used by with expressions for non-destructive mutation. record (or record class, C# 9) is a reference type; record struct (C# 10) is a value type whose positional properties are mutable unless you write readonly record struct. Equality includes an EqualityContract so a base and derived record are never equal. Watch out: with is a shallow copy, collection members compare by reference, and records are a poor fit for EF Core entities, which need identity-based equality.
How to tell it (STAR)
**Situation:** our billing engine sometimes added USD amounts to AUD totals because money was a bare decimal. **Task:** make currency mistakes impossible rather than just fixing one bug. **Action:** I introduced a `Money` value type whose operators check currency, made conversion to decimal explicit, and migrated the billing code with the compiler pointing out every mixed use. **Result:** we found [two] more real currency bugs during migration, and there have been no currency reconciliation errors since.
Last updated 30 Oct 2026.