Operator Overloading in C# — How, Why and When to Use It
Most C# developers use operators every day without thinking about it — adding integers, comparing strings, concatenating values. But the moment you build your own types, those same operators go silent. Try adding two Money objects or comparing two Temperature readings and the compiler stares back at you blankly. That friction is exactly the gap operator overloading was designed to close.
The problem isn't just syntax inconvenience. When your custom type can't use natural operators, callers are forced to write verbose method calls like moneyA.Add(moneyB) instead of moneyA + moneyB. That noise accumulates fast, and it breaks the mental model your type is trying to create. A Vector3 that requires vector.CrossProduct(other) instead of vector * other doesn't feel like a vector — it feels like a bag of helper methods. Operator overloading lets your type fulfill its conceptual promise.
By the end of this article you'll understand exactly which operators C# lets you overload and which it forbids, why certain pairs must always be overloaded together, how to implement a real-world Money struct that supports arithmetic and equality cleanly, and the three mistakes that trip up even experienced developers. You'll also walk away ready to answer the operator overloading questions that regularly show up in .NET interviews.
What Operator Overloading Actually Does Under the Hood
When you write 5 + 3 in C#, the compiler translates that into a call to a static method. For built-in types the runtime handles this invisibly, but the mechanism is the same one you use for your own types. Operator overloading is just declaring a special static method with the keyword operator followed by the symbol you're targeting.
The compiler sees moneyA + moneyB, looks for a public static method on the Money type with the signature operator+(Money, Money), and calls it. If it can't find one, you get a compile-time error — not a runtime crash, which is a nice safety net.
This means there's no magic and no performance penalty beyond a normal static method call. The JIT compiler inlines these calls the same way it does any other small static method, so you're not sacrificing speed for readability.
The key constraint is that at least one parameter must be of the type you're defining. You can't hijack the behaviour of int + int from inside your own class. C# specifically protects built-in type semantics from being overridden by user code.
using System; // A simple struct representing a temperature reading public struct Temperature { public double Celsius { get; } public Temperature(double celsius) { Celsius = celsius; } // The compiler turns 'temp1 + temp2' into this static method call. // Both parameters must relate to our type — here both ARE our type. public static Temperature operator +(Temperature left, Temperature right) { return new Temperature(left.Celsius + right.Celsius); } // Scalar multiplication: allow 'temperature * factor' // One parameter is our type, the other is double — that's allowed. public static Temperature operator *(Temperature temp, double factor) { return new Temperature(temp.Celsius * factor); } // Override ToString so our output is readable public override string ToString() => $"{Celsius:F1}°C"; } class Program { static void Main() { var morningTemp = new Temperature(18.5); var afternoonRise = new Temperature(6.0); // This calls operator+ behind the scenes Temperature peakTemp = morningTemp + afternoonRise; Console.WriteLine($"Peak temperature: {peakTemp}"); // This calls operator* — scalar scaling Temperature doubled = morningTemp * 2.0; Console.WriteLine($"Doubled morning: {doubled}"); } }
Doubled morning: 37.0°C
Building a Real-World Money Type — Arithmetic and Equality Done Right
The most convincing demo of operator overloading isn't a math vector — it's a Money type. Money has rules that match how operators feel: you can add two USD amounts, but adding USD to EUR should throw. That real-world constraint shows you how to put business logic inside an operator, not just delegate to a constructor.
Equality is where most developers stumble. C# has two separate equality concepts: reference equality (are these the same object in memory?) and value equality (do these represent the same value?). For a struct, the default == checks value equality field-by-field using reflection, which is both slow and fragile. For a class, the default == is reference equality, which is almost never what you want for a value-oriented type like Money.
The rule is non-negotiable: if you overload ==, you MUST also overload !=. The compiler enforces this. And whenever you overload ==, you should also override Equals(object) and GetHashCode(), because LINQ, dictionaries, and HashSets all rely on those methods, not on your operator.
This section shows a complete Money struct that gets all of this right, so you have a real template to copy from.
using System; using System.Collections.Generic; public readonly struct Money : IEquatable<Money> { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency code cannot be empty.", nameof(currency)); Amount = Math.Round(amount, 2); // Money is always 2 decimal places Currency = currency.ToUpperInvariant(); } // ── Arithmetic operators ──────────────────────────────────────────── public static Money operator +(Money left, Money right) { // Business rule lives HERE, not scattered across callers EnsureSameCurrency(left, right, "+"); return new Money(left.Amount + right.Amount, left.Currency); } public static Money operator -(Money left, Money right) { EnsureSameCurrency(left, right, "-"); return new Money(left.Amount - right.Amount, left.Currency); } // Scale by a factor — e.g. applying a tax rate public static Money operator *(Money money, decimal factor) { return new Money(money.Amount * factor, money.Currency); } // Allow factor * money as well (commutativity for the caller's convenience) public static Money operator *(decimal factor, Money money) => money * factor; public static bool operator >(Money left, Money right) { EnsureSameCurrency(left, right, ">"); return left.Amount > right.Amount; } public static bool operator <(Money left, Money right) { EnsureSameCurrency(left, right, "<"); return left.Amount < right.Amount; } // ── Equality operators ────────────────────────────────────────────── // Rule: overload == → must also overload != // Rule: overload == → must also override Equals and GetHashCode public static bool operator ==(Money left, Money right) => left.Equals(right); public static bool operator !=(Money left, Money right) => !left.Equals(right); // IEquatable<Money> — used by List.Contains, LINQ, etc. without boxing public bool Equals(Money other) => Amount == other.Amount && string.Equals(Currency, other.Currency, StringComparison.OrdinalIgnoreCase); // Required override when Equals is overridden — used by dictionaries/hashsets public override bool Equals(object? obj) => obj is Money other && Equals(other); public override int GetHashCode() => HashCode.Combine(Amount, Currency.ToUpperInvariant()); public override string ToString() => $"{Currency} {Amount:F2}"; // ── Private helpers ───────────────────────────────────────────────── private static void EnsureSameCurrency(Money left, Money right, string op) { if (!string.Equals(left.Currency, right.Currency, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException( $"Cannot apply '{op}' to {left.Currency} and {right.Currency}. " + $"Convert to the same currency first."); } } class Program { static void Main() { var price = new Money(19.99m, "USD"); var shipping = new Money(4.99m, "USD"); var taxRate = 0.08m; // 8% tax Money subtotal = price + shipping; // calls operator+ Money tax = subtotal * taxRate; // calls operator* Money total = subtotal + tax; Console.WriteLine($"Price: {price}"); Console.WriteLine($"Shipping: {shipping}"); Console.WriteLine($"Tax (8%): {tax}"); Console.WriteLine($"Total: {total}"); Console.WriteLine(); // Equality check works correctly var duplicatePrice = new Money(19.99m, "USD"); Console.WriteLine($"price == duplicatePrice: {price == duplicatePrice}"); Console.WriteLine($"price == total: {price == total}"); Console.WriteLine(); // Works correctly in a HashSet because GetHashCode is consistent with Equals var priceSet = new HashSet<Money> { price, shipping, duplicatePrice }; Console.WriteLine($"HashSet count (price added twice): {priceSet.Count}"); Console.WriteLine(); // Comparison operators Console.WriteLine($"total > price: {total > price}"); // This would throw — different currencies try { var euros = new Money(10.00m, "EUR"); var _ = price + euros; } catch (InvalidOperationException ex) { Console.WriteLine($"Expected error: {ex.Message}"); } } }
Shipping: USD 4.99
Tax (8%): USD 2.00
Total: USD 26.98
price == duplicatePrice: True
price == total: False
HashSet count (price added twice): 2
total > price: True
Expected error: Cannot apply '+' to USD and EUR. Convert to the same currency first.
Which Operators You Can Overload — and the Ones C# Deliberately Blocks
C# gives you a generous but not unlimited set of operators to overload. The choices reflect a deliberate philosophy: operators that control program flow, assignment, or type conversion are off-limits to prevent code that's impossible to reason about.
The operators you CAN overload fall into three groups. Unary operators: +, -, !, ~, ++, --. Binary arithmetic: +, -, *, /, %, &, |, ^, <<, >>. Comparison: ==, !=, <, >, <=, >= (these must be overloaded in symmetric pairs — you can't overload < without also overloading >).
The operators C# deliberately BLOCKS include: &&, ||, =, +=, -=, new, typeof, is, as, and the ternary ?:. The compound assignment operators (+=, -=, etc.) are intentionally excluded because C# derives them automatically. Once you define operator+, the compiler generates += for free. You never write it yourself.
One special case worth knowing: the true and false operators. These are rarely used, but overloading them enables your type to participate in short-circuit && and || evaluation. It's an advanced pattern used in libraries that model nullable or tri-state logic.
using System; public struct Percentage { public double Value { get; } // 0.0 to 100.0 public Percentage(double value) { // Clamp silently — percentages can't exceed 100% or go below 0% Value = Math.Clamp(value, 0.0, 100.0); } // Unary negation — "remaining percentage" // e.g. !Percentage(30) → Percentage(70) means "70% remaining" public static Percentage operator !(Percentage p) => new Percentage(100.0 - p.Value); // Unary increment — nudge up by 1 point public static Percentage operator ++(Percentage p) => new Percentage(p.Value + 1.0); // Binary addition public static Percentage operator +(Percentage left, Percentage right) => new Percentage(left.Value + right.Value); // clamped by constructor // Comparison pair — C# REQUIRES both < and > if you want either public static bool operator <(Percentage left, Percentage right) => left.Value < right.Value; public static bool operator >(Percentage left, Percentage right) => left.Value > right.Value; // Equality pair — C# REQUIRES both == and != if you want either public static bool operator ==(Percentage left, Percentage right) => Math.Abs(left.Value - right.Value) < 0.0001; // float-safe comparison public static bool operator !=(Percentage left, Percentage right) => !(left == right); public override bool Equals(object? obj) => obj is Percentage other && this == other; public override int GetHashCode() => Math.Round(Value, 4).GetHashCode(); public override string ToString() => $"{Value:F1}%"; } class Program { static void Main() { var discount = new Percentage(30.0); var extraOff = new Percentage(15.0); // += is automatically derived from operator+ — we never wrote it Percentage combined = discount; combined += extraOff; Console.WriteLine($"Combined discount: {combined}"); // Unary ! gives us the "remaining" percentage Percentage customerPays = !combined; Console.WriteLine($"Customer pays: {customerPays}"); // ++ operator Percentage bumped = discount; bumped++; Console.WriteLine($"Bumped discount: {bumped}"); // Clamping — adding beyond 100% is safe var huge = new Percentage(80.0) + new Percentage(80.0); Console.WriteLine($"Clamped result: {huge}"); // Comparison Console.WriteLine($"discount > extraOff: {discount > extraOff}"); // Demonstrating that == and != must be paired Console.WriteLine($"discount == extraOff: {discount == extraOff}"); Console.WriteLine($"discount != extraOff: {discount != extraOff}"); } }
Customer pays: 55.0%
Bumped discount: 31.0%
Clamped result: 100.0%
discount > extraOff: True
discount == extraOff: False
discount != extraOff: True
Conversion Operators — Letting Your Type Speak Other Type Languages
Operator overloading has a close sibling that often gets forgotten: conversion operators. These let you define what happens when code tries to cast or implicitly assign your type to another type. There are two flavours: implicit (no cast syntax needed, compiler inserts it silently) and explicit (caller must write the cast, signalling they understand precision might be lost).
The rule of thumb is pragmatic: use implicit conversion only when it is completely safe and lossless — the kind of conversion where no reasonable caller would ever want to be warned. Use explicit when data can be truncated, precision is lost, or an exception might be thrown.
A great real-world example is a Celsius type that converts to Fahrenheit. That conversion is always possible but the result is a different scale, so explicit makes sense. Going the other way, converting from a raw double to Celsius, could be implicit because it's just wrapping a value with no loss.
Conversion operators compose naturally with your arithmetic operators. Once you have a rich conversion story, your type starts to feel like a first-class citizen of the language, not an awkward guest.
using System; public readonly struct Celsius { public double Degrees { get; } public Celsius(double degrees) => Degrees = degrees; // IMPLICIT: wrapping a double in Celsius is always safe — no information lost // Caller can write: Celsius temp = 100.0; (no cast needed) public static implicit operator Celsius(double degrees) => new Celsius(degrees); // EXPLICIT: converting to Fahrenheit changes the scale. // Caller must write: double f = (double)temp; to acknowledge the conversion. // We return double because Fahrenheit is just a double to the outside world here. public static explicit operator double(Celsius celsius) => (celsius.Degrees * 9.0 / 5.0) + 32.0; // Arithmetic still works — combining with previous section's lessons public static Celsius operator +(Celsius left, Celsius right) => new Celsius(left.Degrees + right.Degrees); public override string ToString() => $"{Degrees:F2}°C"; } // A lightweight struct that ONLY holds Fahrenheit — shows cross-type conversion public readonly struct Fahrenheit { public double Degrees { get; } public Fahrenheit(double degrees) => Degrees = degrees; // Explicit conversion FROM Celsius TO Fahrenheit public static explicit operator Fahrenheit(Celsius c) => new Fahrenheit((c.Degrees * 9.0 / 5.0) + 32.0); // Explicit conversion back from Fahrenheit to Celsius public static explicit operator Celsius(Fahrenheit f) => new Celsius((f.Degrees - 32.0) * 5.0 / 9.0); public override string ToString() => $"{Degrees:F2}°F"; } class Program { static void Main() { // Implicit conversion — no cast syntax required Celsius boiling = 100.0; // implicit operator Celsius(double) fires here Console.WriteLine($"Boiling point: {boiling}"); // Explicit conversion to double — caller must acknowledge scale change double boilingF = (double)boiling; Console.WriteLine($"In Fahrenheit (as double): {boilingF:F2}"); // Explicit conversion to Fahrenheit struct Fahrenheit boilingFahrenheit = (Fahrenheit)boiling; Console.WriteLine($"In Fahrenheit (as struct): {boilingFahrenheit}"); // Round-trip: Fahrenheit → Celsius → back to Fahrenheit var bodyTemp = new Fahrenheit(98.6); Celsius bodyTempC = (Celsius)bodyTemp; Fahrenheit roundTrip = (Fahrenheit)bodyTempC; Console.WriteLine($"Body temp round-trip: {bodyTemp} → {bodyTempC} → {roundTrip}"); // Arithmetic on Celsius still works naturally Celsius morning = 15.0; // implicit Celsius afternoon = 8.0; // implicit Celsius peak = morning + afternoon; Console.WriteLine($"Peak: {peak}"); } }
In Fahrenheit (as double): 212.00
In Fahrenheit (as struct): 212.00°F
Body temp round-trip: 98.60°F → 37.00°C → 98.60°F
Peak: 23.00°C
| Aspect | Operator Overloading | Regular Methods |
|---|---|---|
| Syntax at call site | price + tax (natural, concise) | price.Add(tax) (verbose, method-flavoured) |
| Discoverability | Relies on docs or IDE hints — not obvious a '+' is defined | Shows up in IntelliSense autocomplete immediately |
| Best fit for | Value types that model a real-world quantity (Money, Vector, Temperature) | Operations with side effects, multiple return values, or complex parameters |
| Compound assignment (+=) | Automatically derived once operator+ exists — you write nothing extra | Must write AddInPlace or similar method manually |
| Interoperability | Other CLR languages call op_Addition by name — works but ugly | Universally callable from any CLR language using standard syntax |
| Enforcement of symmetry | C# forces you to define == and != together, < and > together | No automatic pairing — easy to forget the inverse method |
| Risk of misuse | Can create confusing code if semantics don't match the symbol | Method name is always explicit — harder to mislead |
| Performance | Inlined by JIT — effectively zero overhead for simple struct operations | Same — also inlined for simple cases, no meaningful difference |
🎯 Key Takeaways
- Operator overloading compiles to a static method named op_Addition, op_Equality, etc. — it's ordinary method dispatch with no hidden magic or runtime cost.
- Overloading == forces you to also overload != (compiler error otherwise), and you must override Equals + GetHashCode to stay consistent with LINQ, Dictionary, and HashSet behaviour.
- Compound assignment operators (+=, -=, *=) are automatically synthesised by the compiler once you define the base operator — you never write them yourself, and attempting to do so is a compile error.
- Use implicit conversions only when the conversion is completely lossless and safe; use explicit when precision is lost, the scale changes, or an exception is possible — this keeps callers informed of meaningful type boundaries.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Overloading == without overriding Equals and GetHashCode — Your == works in simple comparisons, but LINQ's .Contains(), Dictionary lookups, and HashSet deduplication use Equals/GetHashCode internally, not your operator. The symptom is a Dictionary not finding a key that you know is there, or a HashSet that contains duplicates. Fix: always override Equals(object) and GetHashCode() whenever you overload ==, and implement IEquatable
so LINQ uses the typed, non-boxing path. - ✕Mistake 2: Making implicit conversion operators too broad — You define an implicit conversion from string to your type to avoid cast syntax, and suddenly every method that accepts your type silently accepts raw strings. The symptom is a wrong overload resolving at runtime, or a null string being converted and throwing inside your constructor with a confusing stack trace. Fix: use implicit only when the source and target types are conceptually the same thing. If there's any doubt, make it explicit.
- ✕Mistake 3: Overloading operators on mutable classes instead of structs — You overload + on a class, and the operator creates a new instance as expected. But a junior developer on your team writes account += deposit thinking it modifies account in place — it actually replaces the reference with a new object, silently discarding any other references to the original object. Fix: model value types (Money, Vector, Percentage) as readonly struct. The immutability makes operator semantics correct by design — operators always return new values, never mutate.
Interview Questions on This Topic
- QWhy does C# require you to overload == and != together, and what happens if you override Equals without also overriding GetHashCode?
- QWhat is the difference between implicit and explicit conversion operators in C#? Give a concrete example of when you'd choose each.
- QCan you overload the += operator in C#? How does compound assignment actually work for overloaded types?
Frequently Asked Questions
Can you overload all operators in C#?
No. C# allows overloading of arithmetic, bitwise, comparison, and unary operators, but deliberately blocks assignment (=), conditional logic (&&, ||), and flow-control operators (new, typeof, is, as). The compound assignment operators like += are also blocked because C# derives them automatically from the base operator, ensuring a + b and a += b are always consistent.
Should I overload operators on a class or a struct in C#?
Prefer struct for types that model a value — Money, Vector, Temperature, Percentage. Operators on structs naturally return new values rather than mutating state, which matches how arithmetic operators feel to callers. On a class, operator+ returning a new instance can confuse developers who expect in-place mutation, especially with compound assignment. If your type is inherently reference-based (e.g. a mutable entity with identity), stick to regular methods.
Does overloading == in C# replace the Object.Equals method?
No, and this is a common source of bugs. The == operator and Object.Equals are separate mechanisms. The == operator is a static method resolved at compile time based on the declared type of the variables. Equals is a virtual method resolved at runtime. If you overload == without overriding Equals, types stored in object variables, passed to collections, or compared via LINQ will still use the default reference-equality Equals. Always override both together.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.