The Pitfall of Incomplete Equality
It's tempting to overload the == operator to give your custom types value semantics, making comparisons feel natural. It works—until you add those objects to a Dictionary or HashSet and discover they're treated as different keys even when == returns true. The problem is you overloaded == but forgot Equals and GetHashCode.
Operator overloading lets you define custom behavior for built-in operators like ==, +, or <. For value types and immutable domain objects, this creates intuitive comparison syntax. However, C# requires consistency across multiple equality mechanisms: ==, Equals, and GetHashCode must all agree, or collections and LINQ will behave unexpectedly.
You'll learn how to overload == and != operators correctly, implement Equals and GetHashCode for hash-based collections, handle null comparisons safely, understand when to overload arithmetic operators for custom numeric types, and test equality implementations to avoid subtle bugs.
Overloading == and != Operators
The equality operators == and != must be overloaded as a pair using public static methods. They take two parameters of your type and return bool. For value semantics, compare all significant fields. Always handle null cases explicitly to avoid NullReferenceException.
public class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
// Overload == operator
public static bool operator ==(Money? left, Money? right)
{
// Handle null cases
if (ReferenceEquals(left, right)) return true;
if (left is null || right is null) return false;
// Value comparison
return left.Amount == right.Amount &&
left.Currency == right.Currency;
}
// Must overload != when overloading ==
public static bool operator !=(Money? left, Money? right)
{
return !(left == right);
}
// Must override Equals
public override bool Equals(object? obj)
{
return Equals(obj as Money);
}
// Implement IEquatable<T> for type safety
public bool Equals(Money? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
return Amount == other.Amount && Currency == other.Currency;
}
// Must override GetHashCode
public override int GetHashCode()
{
return HashCode.Combine(Amount, Currency);
}
public override string ToString() => $"{Amount:C} {Currency}";
}
The operator methods are static because they work on two operands that might be null. The ReferenceEquals check handles cases where both references point to the same object. Delegating != to == ensures consistent logic. GetHashCode uses HashCode.Combine for proper distribution across hash buckets.
Overloading Arithmetic Operators
For types representing mathematical concepts like vectors, complex numbers, or monetary values, arithmetic operators make code readable. You can overload +, -, *, /, and others. Pair related operators (+ with -, * with /) and maintain mathematical properties like commutativity where appropriate.
public readonly struct Vector2D : IEquatable<Vector2D>
{
public double X { get; }
public double Y { get; }
public Vector2D(double x, double y)
{
X = x;
Y = y;
}
// Addition operator
public static Vector2D operator +(Vector2D a, Vector2D b)
{
return new Vector2D(a.X + b.X, a.Y + b.Y);
}
// Subtraction operator
public static Vector2D operator -(Vector2D a, Vector2D b)
{
return new Vector2D(a.X - b.X, a.Y - b.Y);
}
// Unary negation
public static Vector2D operator -(Vector2D v)
{
return new Vector2D(-v.X, -v.Y);
}
// Scalar multiplication
public static Vector2D operator *(Vector2D v, double scalar)
{
return new Vector2D(v.X * scalar, v.Y * scalar);
}
// Commutative multiplication
public static Vector2D operator *(double scalar, Vector2D v)
{
return v * scalar;
}
// Equality operators
public static bool operator ==(Vector2D left, Vector2D right)
{
return left.Equals(right);
}
public static bool operator !=(Vector2D left, Vector2D right)
{
return !left.Equals(right);
}
public override bool Equals(object? obj)
{
return obj is Vector2D vector && Equals(vector);
}
public bool Equals(Vector2D other)
{
return X == other.X && Y == other.Y;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y);
}
public override string ToString() => $"({X}, {Y})";
}
// Usage
var v1 = new Vector2D(3, 4);
var v2 = new Vector2D(1, 2);
var sum = v1 + v2; // (4, 6)
var diff = v1 - v2; // (2, 2)
var scaled = v1 * 2; // (6, 8)
var negated = -v1; // (-3, -4)
Readonly structs are perfect for value types with operators since they're immutable and stack-allocated. The commutative multiplication operators let you write both v * 2 and 2 * v naturally. Operators return new instances rather than modifying existing ones, maintaining immutability.
Overloading Comparison Operators
When your type has a natural ordering, overload <, >, <=, >= operators and implement IComparable<T>. This enables sorting, LINQ ordering, and range comparisons. Always define these operators in pairs to maintain consistency.
public readonly struct Temperature : IComparable<Temperature>,
IEquatable<Temperature>
{
public double Celsius { get; }
public Temperature(double celsius)
{
Celsius = celsius;
}
// Less than
public static bool operator <(Temperature left, Temperature right)
{
return left.Celsius < right.Celsius;
}
// Greater than
public static bool operator >(Temperature left, Temperature right)
{
return left.Celsius > right.Celsius;
}
// Less than or equal
public static bool operator <=(Temperature left, Temperature right)
{
return left.Celsius <= right.Celsius;
}
// Greater than or equal
public static bool operator >=(Temperature left, Temperature right)
{
return left.Celsius >= right.Celsius;
}
// Equality operators
public static bool operator ==(Temperature left, Temperature right)
{
return left.Celsius == right.Celsius;
}
public static bool operator !=(Temperature left, Temperature right)
{
return left.Celsius != right.Celsius;
}
// IComparable implementation
public int CompareTo(Temperature other)
{
return Celsius.CompareTo(other.Celsius);
}
// IEquatable implementation
public bool Equals(Temperature other)
{
return Celsius == other.Celsius;
}
public override bool Equals(object? obj)
{
return obj is Temperature temp && Equals(temp);
}
public override int GetHashCode()
{
return Celsius.GetHashCode();
}
public override string ToString() => $"{Celsius}°C";
}
// Usage
var freezing = new Temperature(0);
var boiling = new Temperature(100);
var warm = new Temperature(25);
if (warm > freezing && warm < boiling)
{
Console.WriteLine("Water is liquid at this temperature");
}
var temps = new[] { boiling, freezing, warm };
Array.Sort(temps); // Works due to IComparable
The comparison operators delegate to the underlying Celsius value for simplicity. IComparable<T> is required for Array.Sort and List<T>.Sort to work. Implementing both operators and the interface ensures your type works seamlessly with .NET collection APIs.
Conversion Operators
Implicit and explicit conversion operators let you define type conversions. Use implicit for safe, lossless conversions and explicit for conversions that might lose precision or fail. This makes your types integrate naturally with existing code.
public readonly struct Percentage
{
private readonly double _value; // Stored as 0.0 to 1.0
private Percentage(double value)
{
if (value < 0 || value > 1)
throw new ArgumentOutOfRangeException(nameof(value));
_value = value;
}
public static Percentage FromDecimal(double value) => new(value);
public static Percentage FromPercent(double percent) => new(percent / 100);
// Implicit conversion to double (safe, no loss)
public static implicit operator double(Percentage p)
{
return p._value;
}
// Explicit conversion from double (requires validation)
public static explicit operator Percentage(double value)
{
return FromDecimal(value);
}
// Arithmetic with conversion
public static Percentage operator +(Percentage a, Percentage b)
{
return FromDecimal(Math.Min(a._value + b._value, 1.0));
}
public override string ToString() => $"{_value * 100:F1}%";
}
// Usage
Percentage discount = Percentage.FromPercent(15);
double discountValue = discount; // Implicit conversion
Percentage taxRate = (Percentage)0.08; // Explicit conversion
var total = discount + taxRate;
Implicit conversions happen automatically without casts, so use them sparingly and only when safe. Explicit conversions require a cast, signaling to readers that something non-trivial is happening. The private constructor forces use of named factory methods, making intent clearer at call sites.
Testing and Validation
Always test operator overloads thoroughly. Verify null handling, symmetry (a == b implies b == a), transitivity (a == b and b == c implies a == c), and consistency between ==, Equals, and GetHashCode. Test that collections behave correctly.
using Xunit;
public class MoneyTests
{
[Fact]
public void Equality_WithSameValues_ReturnsTrue()
{
var m1 = new Money(100, "USD");
var m2 = new Money(100, "USD");
Assert.True(m1 == m2);
Assert.True(m1.Equals(m2));
Assert.Equal(m1.GetHashCode(), m2.GetHashCode());
}
[Fact]
public void Equality_WithDifferentCurrency_ReturnsFalse()
{
var usd = new Money(100, "USD");
var eur = new Money(100, "EUR");
Assert.False(usd == eur);
Assert.True(usd != eur);
}
[Fact]
public void Equality_WithNull_ReturnsFalse()
{
var money = new Money(100, "USD");
Assert.False(money == null);
Assert.False(null == money);
Assert.True(null == (Money?)null);
}
[Fact]
public void HashCode_AllowsDictionaryUsage()
{
var dict = new Dictionary<Money, string>
{
[new Money(100, "USD")] = "One hundred dollars"
};
var key = new Money(100, "USD");
Assert.True(dict.ContainsKey(key));
Assert.Equal("One hundred dollars", dict[key]);
}
}
The dictionary test is crucial—it verifies that Equals and GetHashCode work correctly for hash-based collections. Many bugs appear only when using types as dictionary keys or in HashSet. Testing all equality mechanisms together prevents subtle inconsistencies.
Try It Yourself
Build a console app with a custom type that overloads operators and works correctly with collections.
Steps
- Create:
dotnet new console -n OperatorDemo
- Navigate:
cd OperatorDemo
- Replace Program.cs
- Run:
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console.WriteLine("=== Operator Overloading Demo ===\n");
var m1 = new Money(100, "USD");
var m2 = new Money(100, "USD");
var m3 = new Money(50, "USD");
Console.WriteLine($"m1 == m2: {m1 == m2}");
Console.WriteLine($"m1 == m3: {m1 == m3}");
// Test with Dictionary
var prices = new Dictionary<Money, string>
{
[new Money(100, "USD")] = "Premium Plan"
};
Console.WriteLine($"\nDictionary lookup: {prices[m1]}");
public class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public static bool operator ==(Money? left, Money? right)
{
if (ReferenceEquals(left, right)) return true;
if (left is null || right is null) return false;
return left.Amount == right.Amount && left.Currency == right.Currency;
}
public static bool operator !=(Money? left, Money? right) => !(left == right);
public override bool Equals(object? obj) => Equals(obj as Money);
public bool Equals(Money? other)
{
if (other is null) return false;
return Amount == other.Amount && Currency == other.Currency;
}
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
}
Console
=== Operator Overloading Demo ===
m1 == m2: True
m1 == m3: False
Dictionary lookup: Premium Plan