C# Operator Overloading: No GetHashCode Breaks HashSet
A missing GetHashCode when overloading operator== caused duplicate HashSet entries in payment reconciliation, wasting hours.
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Operator overloading lets your structs and classes use C# operators (+, ==, etc.) naturally.
- It's syntactic sugar over static methods — the compiler translates
a + btooperator+. - Compound assignment (+=, -=) is auto-derived from the base operator.
- Overloading == forces you to also overload != and override Equals + GetHashCode.
- Implicit conversions require lossless safety; explicit conversions signal possible data loss.
Imagine you have two LEGO bags. You want to 'add' them together to get one big bag of bricks. Normally a computer doesn't know what 'adding two bags' means — it only knows how to add numbers. Operator overloading is you teaching the computer exactly what '+' means for YOUR kind of bag. Once you've done that, writing bag1 + bag2 just works, the same way 3 + 4 works. It's giving a familiar symbol a new job for your custom type.
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.
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.
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.
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.
The true and false Operators — Enabling Short-Circuit Evaluation in Custom Types
Most developers never need the true and false operators. They are the gateway to making your type work with && and || in short-circuit evaluation. Without them, you cannot use your custom type in logical expressions the way nullable Booleans do.
The use case is modeling a tri-state Boolean — a value that can be true, false, or indeterminate. Libraries like nullable Booleans in databases, or condition models in rule engines, use this pattern. The compiler requires both true and false operators to be defined together. Once you have them, you can use the & and | operators combined with them, and then && and || become available.
Here's how it works: for &&, the compiler evaluates the left operand using the true operator. If it returns true, it evaluates the right operand; otherwise it short-circuits to false. For ||, it uses the false operator similarly. This is how nullable bools like bool? work internally.
Let's implement a simple ThreeState type that supports logical operations.
- For a type to support &&, it must define & and both true/false operators.
- For ||, define | and the same true/false operators.
- The compiler uses the true operator to decide whether to short-circuit in && (if left is not true, skip right).
- Use this pattern for nullable, tri-state, or fuzzy logic types.
Overloading Binary Operators — Why Your Code Smells Without Them
Binary operators are the workhorses of arithmetic. If you're writing a Money type and you've got Add(Money other) instead of +, you're writing java-with-training-wheels. C# gives you +, -, *, /, %. Use them.
The compiler rewrites a + b into a static method call. That public static Money operator +(Money left, Money right) isn't magic — it's a named function the compiler knows to call when it sees the plus sign. The operands are the parameters, the return type is the result. No exceptions for null unless you write them.
Here's the trap I've seen catch five juniors: binary operators must return the same type as at least one operand. You can't write operator +(Order, Product) that returns a decimal. That's not operator overloading, that's a static method wearing a costume. Keep the contract tight: additive operators return something that can be chained into another expression.
+ to mean concatenation for a numeric type. I've seen a Money + Money implementation that summed amounts but appended currency strings. The accounting system accepted it for three months. The auditors were not amused.Overloading Unary Operators — The One-Liner That Changes Flow
Unary operators (++, --, !, ~, +, -) take one operand. They look trivial but they're the difference between counter = counter. and Increment()counter++. The latter is cleaner, but only if you respect the semantics.
For ++ and --, the compiler generates separate code for prefix vs postfix. The prefix form returns the new value; postfix returns the old. C# handles this by calling your overload and then either returning the result (prefix) or taking a snapshot first (postfix). You just write one method — the compiler does the bookkeeping.
But here's where production code bites back: ++ on a mutable struct. Don't. Your overload creates a new instance. The caller expects the original to change. Use readonly struct or a class. Pick one. Every time I see a struct with a mutable ++ overload, I find a race condition within fifty lines.
! and ~ are great for flag enums or validation results. Overload ! to mean "is invalid" on a ValidationResult type. It reads like natural language: if (!validationResult).
++, also overload == and !=. Otherwise counter++ == 5 is a compile error or confusing behavior. These operators travel in packs.++ on a struct, the struct must be immutable — the return is a new instance, not a mutation.Conversion Operators — Implicit vs Explicit: Don't Let the Compiler Guess Wrong
You've written a custom Money type. Now your boss wants to pass it to a legacy API that takes decimal. You could expose a .ToDecimal() method, sure. But that's noise. The real solution: conversion operators.
Conversion operators tell the compiler how to treat your type as another type — either implicitly (no cast required) or explicitly (requires (TargetType) cast). The rule of thumb: if the conversion can lose data or throw, make it explicit. If it's always safe, implicit is fine.
Implicit conversions look clean but hide bugs. Explicit conversions look ugly but signal risk. Your Money to decimal is safe — always exact. Implicit. But decimal to Money? Currency rounding might truncate. That's explicit territory. C# forces you to pick. Pick wisely — your code reviewers will thank you.
The true and false Operators — Enabling Short-Circuit Evaluation in Custom Types
C# lets you overload && and || for your own types. But there's a catch: the language forces you to implement the true and false operators first. Why? Because short-circuit evaluation needs a binary decision — is this value truthy or falsy?
Think of it like nullable bools. Nullable<bool> has three states: true, false, null. When you write nullableBool && somethingElse, the compiler can't short-circuit unless it knows for sure the first operand is false. That's exactly what the true and false operators provide: a deterministic yes/no answer.
Your custom type — say a Validated<T> — can overload these to support clean conditional logic. When you write if (validated) { ... }, the compiler calls operator true. When you write if (!validated) { ... }, it calls operator false. Implement them correctly, and your type becomes a first-class citizen in boolean expressions.
Checked User-Defined Operators
C# allows you to define checked versions of arithmetic operators (+, -, *, /) to handle overflow explicitly. By prefixing the operator keyword with checked, you create a version that throws an OverflowException when the result exceeds the type's range. This is particularly useful for custom numeric types like Money where overflow should be treated as an error rather than silent wrapping.
Example: Define a checked addition operator for a Money struct that stores cents as a long:
public static Money operator checked +(Money a, Money b)
{
checked
{
return new Money(a.Cents + b.Cents);
}
}
When the checked context is active (either via compiler option or checked block), the runtime will call the checked operator. In an unchecked context, the regular operator is used. This dual behavior allows callers to choose overflow behavior.
Note: You must also define the unchecked version (the regular operator) if you define a checked one. The compiler enforces this pairing.
checked keyword explicitly in critical calculations.User-Defined Conversion Operators (implicit/explicit)
Conversion operators allow your custom type to be converted to or from another type. They are defined as implicit (no cast required) or explicit (cast required). Use implicit conversions when the conversion is always safe and lossless; use explicit when it might lose data or throw.
Example: A Money type that can be implicitly converted from decimal (safe, no loss) and explicitly converted to decimal (might lose cents if rounding):
public readonly struct Money
{
public decimal Amount { get; }
public Money(decimal amount) => Amount = amount;
// Implicit: decimal -> Money (always safe)
public static implicit operator Money(decimal value) => new Money(value);
// Explicit: Money -> decimal (may lose precision if rounding)
public static explicit operator decimal(Money money) => money.Amount;
}
Usage: ``csharp Money m = 42.5m; // implicit conversion decimal d = (decimal)m; // explicit conversion ``
Rules: You cannot define conversions to/from object or interfaces. Implicit conversions should not throw exceptions. Explicit conversions can throw if the source value is out of range.
Operator Overloading vs Extension Methods
Operator overloading and extension methods serve different purposes but can sometimes achieve similar results. Operator overloading allows custom types to use built-in operators (+, -, etc.) naturally, while extension methods add new methods to existing types without modifying them.
- Your type represents a value that has natural mathematical or logical operations (e.g., Vector, Money, Complex).
- You want to maintain readability and consistency with primitive types.
- You cannot modify the original type (e.g., adding
SumtoIEnumerable). - The operation is not a standard operator (e.g.,
Normalize()on a Vector). - You want to provide multiple overloads with different names.
Example: Instead of overloading + for Money, you could define an extension method Add. But + is more intuitive:
```csharp // Operator overloading var total = money1 + money2;
// Extension method alternative var total = money1.Add(money2); ```
Operator overloading is more expressive for domain-specific types, but extension methods are more flexible when you don't own the type or need semantic naming.
The Missing GetHashCode That Broke Payment Reconciliation
Contains() returned false and duplicates were allowed.- Overriding == without GetHashCode is a data-corruption bug, not a warning.
- Treat compiler warning CS0659 as a hard error.
- Always implement IEquatable<T> on structs to avoid boxing.
- Test your type in a HashSet before production.
Inspect the type: `typeof(MyType).GetMethods() | Where-Object { $_.Name -match 'GetHashCode|Equals' }`Check if GetHashCode uses all fields: `HashCode.Combine(Field1, Field2)`| File | Command / Code | Purpose |
|---|---|---|
| OperatorBasics.cs | using System; | What Operator Overloading Actually Does Under the Hood |
| Money.cs | using System; | Building a Real-World Money Type |
| OperatorRulesDemo.cs | using System; | Which Operators You Can Overload |
| ConversionOperators.cs | using System; | Conversion Operators |
| ThreeStateBoolean.cs | using System; | The true and false Operators |
| MoneyWithBinaryOperators.cs | public readonly struct Money | Overloading Binary Operators |
| UnaryOperatorsInProduction.cs | public readonly struct Counter | Overloading Unary Operators |
| ConversionExample.cs | public readonly struct Money | Conversion Operators |
| TrueFalseExample.cs | public readonly struct Validated | The true and false Operators |
| CheckedOperators.cs | public readonly struct Money | Checked User-Defined Operators |
| ConversionOperators.cs | public readonly struct Money | User-Defined Conversion Operators (implicit/explicit) |
| OperatorVsExtension.cs | public readonly struct Money | Operator Overloading vs Extension Methods |
Key takeaways
Interview Questions on This Topic
Why does C# require you to overload == and != together, and what happens if you override Equals without also overriding GetHashCode?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
That's OOP in C#. Mark it forged?
8 min read · try the examples if you haven't