Equality and Operator Overloading

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

"Are these two things the same?" has several answers in C#, and the type decides which one you get. This page covers what the defaults are, how to change them correctly, and how to give a type its own operators.

Reference Equality versus Value Equality

public class PlainClass
{
    public int Value { get; init; }
}

public struct PlainStruct
{
    public int Value { get; init; }
}

public record RecordType(int Value);

public static class DefaultEquality
{
    public static void Run()
    {
        var c1 = new PlainClass { Value = 1 };
        var c2 = new PlainClass { Value = 1 };
        Console.WriteLine(c1 == c2);          // False -- reference identity
        Console.WriteLine(c1.Equals(c2));     // False

        var s1 = new PlainStruct { Value = 1 };
        var s2 = new PlainStruct { Value = 1 };
        // Console.WriteLine(s1 == s2);       // error CS0019: no == on a plain struct
        Console.WriteLine(s1.Equals(s2));     // True -- ValueType.Equals compares fields

        var r1 = new RecordType(1);
        var r2 = new RecordType(1);
        Console.WriteLine(r1 == r2);          // True -- synthesised value equality
        Console.WriteLine(r1.Equals(r2));     // True

        Console.WriteLine(ReferenceEquals(r1, r2));   // False -- still distinct objects
        Console.WriteLine(object.Equals(null, null)); // True  -- null-safe static helper
    }
}
Type Default Equals Default ==

class

Reference identity

Reference identity

struct

Field-by-field (via reflection — slow)

Not defined — a compile error

record class

Type + all fields

Type + all fields

record struct

All fields

All fields

string

Ordinal content

Ordinal content (overloaded)

Most BCL value types

Content

Content (overloaded)

The reflection-based ValueType.Equals is the main reason to prefer a record struct — or to implement IEquatable<T> by hand — over a plain struct you compare often.

Overriding Equals and GetHashCode

If you override one, you must override the other. The contract:

  • x.Equals(x) is true (reflexive).

  • x.Equals(y) equals y.Equals(x) (symmetric).

  • If x.Equals(y) and y.Equals(z) then x.Equals(z) (transitive).

  • It is consistent — repeated calls give the same answer while nothing changes.

  • x.Equals(null) is false.

  • Equal objects must have equal hash codes. Unequal objects may share one.

  • Neither method throws.

public sealed class Money2 : IEquatable<Money2>
{
    public Money2(decimal amount, string currency)
        => (Amount, Currency) = (amount, currency);

    public decimal Amount { get; }
    public string Currency { get; }

    // The strongly-typed overload: no boxing, no cast.
    public bool Equals(Money2? other)
        => other is not null
           && Amount == other.Amount
           && string.Equals(Currency, other.Currency, StringComparison.Ordinal);

    public override bool Equals(object? obj) => Equals(obj as Money2);

    // HashCode.Combine handles up to eight members and mixes them properly.
    public override int GetHashCode() => HashCode.Combine(Amount, Currency);

    public static bool operator ==(Money2? left, Money2? right)
        => left is null ? right is null : left.Equals(right);

    public static bool operator !=(Money2? left, Money2? right) => !(left == right);
}

Never derive a hash code from mutable state. Put an object in a Dictionary or HashSet, mutate a member that GetHashCode reads, and the object becomes unreachable in its own collection — it hashes to a different bucket than the one it sits in. Base equality on immutable state only, which is another argument for records.

For a hierarchy, compare the runtime type so a base instance never equals a derived one:

public class Point2D : IEquatable<Point2D>
{
    public Point2D(int x, int y) => (X, Y) = (x, y);

    public int X { get; }
    public int Y { get; }

    public virtual bool Equals(Point2D? other)
        => other is not null
           && GetType() == other.GetType()      // not `other is Point2D` -- that breaks symmetry
           && X == other.X && Y == other.Y;

    public override bool Equals(object? obj) => Equals(obj as Point2D);
    public override int GetHashCode() => HashCode.Combine(GetType(), X, Y);
}

public sealed class Point3DDerived(int x, int y, int z) : Point2D(x, y), IEquatable<Point3DDerived>
{
    public int Z { get; } = z;

    public bool Equals(Point3DDerived? other) => base.Equals(other) && Z == other!.Z;
    public override bool Equals(Point2D? other) => Equals(other as Point3DDerived);
    public override bool Equals(object? obj) => Equals(obj as Point3DDerived);
    public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Z);
}

A record does all of this correctly via EqualityContract, which is the strongest reason to use one for data.

IEquatable<T>, IComparable<T> and Comparers

public readonly struct SemanticVersion
    : IEquatable<SemanticVersion>, IComparable<SemanticVersion>
{
    public SemanticVersion(int major, int minor, int patch)
        => (Major, Minor, Patch) = (major, minor, patch);

    public int Major { get; }
    public int Minor { get; }
    public int Patch { get; }

    public bool Equals(SemanticVersion other)
        => Major == other.Major && Minor == other.Minor && Patch == other.Patch;

    public override bool Equals(object? obj) => obj is SemanticVersion other && Equals(other);
    public override int GetHashCode() => HashCode.Combine(Major, Minor, Patch);

    public int CompareTo(SemanticVersion other)
    {
        int byMajor = Major.CompareTo(other.Major);
        if (byMajor != 0)
        {
            return byMajor;
        }

        int byMinor = Minor.CompareTo(other.Minor);
        return byMinor != 0 ? byMinor : Patch.CompareTo(other.Patch);
    }

    public static bool operator ==(SemanticVersion left, SemanticVersion right) => left.Equals(right);
    public static bool operator !=(SemanticVersion left, SemanticVersion right) => !left.Equals(right);
    public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0;
    public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0;
    public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0;
    public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0;

    public override string ToString() => $"{Major}.{Minor}.{Patch}";
}

Implementing IComparable<T> means declaring <, >, , >=, == and != too (analyzer CA1036), so the operators agree with CompareTo.

When the order or equality is not intrinsic to the type — or you need several — supply a comparer instead:

public sealed class CaseInsensitiveNameComparer : IEqualityComparer<string>, IComparer<string>
{
    public static readonly CaseInsensitiveNameComparer Instance = new();

    public bool Equals(string? x, string? y)
        => string.Equals(x, y, StringComparison.OrdinalIgnoreCase);

    public int GetHashCode(string obj)
        => obj.GetHashCode(StringComparison.OrdinalIgnoreCase);

    public int Compare(string? x, string? y)
        => string.Compare(x, y, StringComparison.OrdinalIgnoreCase);
}

public static class ComparerDemo
{
    public static void Run()
    {
        var set = new HashSet<string>(CaseInsensitiveNameComparer.Instance) { "Ada" };
        Console.WriteLine(set.Contains("ADA"));        // True

        string[] names = ["banana", "Apple", "cherry"];
        Array.Sort(names, CaseInsensitiveNameComparer.Instance);
        Console.WriteLine(string.Join(",", names));    // Apple,banana,cherry

        // EqualityComparer<T>.Default picks IEquatable<T> when available, and does not box.
        Console.WriteLine(EqualityComparer<SemanticVersion>.Default
            .Equals(new(1, 0, 0), new(1, 0, 0)));      // True

        // Comparer<T>.Create builds one from a lambda.
        IComparer<string> byLength = Comparer<string>.Create((a, b) => a.Length.CompareTo(b.Length));
        Console.WriteLine(byLength.Compare("ab", "abc") < 0);
    }
}

EqualityComparer<T>.Default and Comparer<T>.Default are what every BCL collection uses when you do not supply one, and they are the right way to compare T in generic code.

Overloading Operators

An overloaded operator is a public static method named operator X. Some come in mandatory pairs.

public readonly struct Vector3
{
    public Vector3(double x, double y, double z) => (X, Y, Z) = (x, y, z);

    public double X { get; }
    public double Y { get; }
    public double Z { get; }

    // Binary arithmetic
    public static Vector3 operator +(Vector3 a, Vector3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
    public static Vector3 operator -(Vector3 a, Vector3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
    public static Vector3 operator *(Vector3 v, double k)  => new(v.X * k, v.Y * k, v.Z * k);
    public static Vector3 operator *(double k, Vector3 v)  => v * k;      // both orders
    public static Vector3 operator /(Vector3 v, double k)  => new(v.X / k, v.Y / k, v.Z / k);

    // Unary
    public static Vector3 operator -(Vector3 v) => new(-v.X, -v.Y, -v.Z);
    public static Vector3 operator +(Vector3 v) => v;

    // Equality -- == and != must be declared together
    public static bool operator ==(Vector3 a, Vector3 b)
        => a.X == b.X && a.Y == b.Y && a.Z == b.Z;
    public static bool operator !=(Vector3 a, Vector3 b) => !(a == b);

    public override bool Equals(object? obj) => obj is Vector3 v && this == v;
    public override int GetHashCode() => HashCode.Combine(X, Y, Z);

    public double Length => Math.Sqrt((X * X) + (Y * Y) + (Z * Z));
    public override string ToString() => $"({X}, {Y}, {Z})";
}
Operators Rule

+ - * / % & | ^ << >> >>>

Overloadable individually.

` `-` `!` `~` `+ -- true false

Unary; true/false must be declared as a pair.

== !=

Must be declared as a pair. Overriding Equals/GetHashCode too is required in practice.

< > and >=

Each pair must be declared together.

&& ||

Not directly overloadable — they follow from &/| plus true/false.

[]

Not an operator — write an indexer.

()

Not an operator — write a conversion.

= ?: ?? . new typeof is as sizeof

Not overloadable at all.

checked Operators

C# 11 lets a type provide an overflow-checking variant, used inside a checked context:

public readonly struct SmallInt
{
    private readonly int _value;
    public SmallInt(int value) => _value = value;

    public static SmallInt operator +(SmallInt a, SmallInt b)
        => new(unchecked(a._value + b._value));

    public static SmallInt operator checked +(SmallInt a, SmallInt b)
        => new(checked(a._value + b._value));      // used in a `checked` context

    public override string ToString() => _value.ToString(CultureInfo.InvariantCulture);
}

User-Defined Compound Assignment Operators (C# 14)

Before C# 14, x += y always compiled to x = x + y — a new instance per step. C# 14 lets a type define the compound operator in place, which matters for mutable accumulators:

public sealed class Accumulator
{
    private readonly List<int> _items = [];

    public int Count => _items.Count;

    // C# 14: an instance member, returning void, mutating in place.
    public void operator +=(int value) => _items.Add(value);

    public void operator -=(int value) => _items.Remove(value);

    // C# 14 also allows user-defined ++ and -- in the same in-place form.
    public void operator ++() => _items.Add(_items.Count);

    // The classic binary operator still exists for the non-mutating case.
    public static Accumulator operator +(Accumulator left, int value)
    {
        var result = new Accumulator();
        result._items.AddRange(left._items);
        result._items.Add(value);
        return result;
    }
}

public static class CompoundAssignmentDemo
{
    public static void Run()
    {
        var accumulator = new Accumulator();

        accumulator += 1;      // calls the instance `operator +=` -- no new Accumulator
        accumulator += 2;
        accumulator++;

        Console.WriteLine(accumulator.Count);   // 3
    }
}

The compiler prefers the user-defined compound operator when one exists and the target is a variable; otherwise it falls back to x = x + y.

User-Defined Conversions

public readonly struct Celsius
{
    public Celsius(double degrees) => Degrees = degrees;
    public double Degrees { get; }

    // implicit: always safe, never loses information, never throws.
    public static implicit operator Celsius(double degrees) => new(degrees);
    public static implicit operator double(Celsius temperature) => temperature.Degrees;

    // explicit: may lose information or throw -- the caller must write the cast.
    public static explicit operator Fahrenheit(Celsius temperature)
        => new((temperature.Degrees * 9 / 5) + 32);

    public override string ToString() => $"{Degrees:F1} °C";
}

public readonly struct Fahrenheit(double degrees)
{
    public double Degrees { get; } = degrees;
    public override string ToString() => $"{Degrees:F1} °F";
}

public static class ConversionDemo
{
    public static void Run()
    {
        Celsius body = 37.0;                       // implicit from double
        double asNumber = body;                    // implicit to double
        var inFahrenheit = (Fahrenheit)body;       // explicit

        Console.WriteLine($"{body} = {inFahrenheit} ({asNumber})");
    }
}

The guidance from the framework design guidelines: make a conversion implicit only when it cannot fail and cannot surprise; make it explicit otherwise; and prefer a named method (ToFahrenheit(), Parse) over a conversion when the operation is expensive or the direction is not obvious. Conversions to and from object, to or from a base or derived type, and between two types where neither is yours, are all forbidden.

Operators via Static Abstract Interface Members

Generic code gets operators through interfaces (C# 11). Implement the numeric interfaces and your type works with every generic-math algorithm:

public readonly struct Meters2 :
    IAdditionOperators<Meters2, Meters2, Meters2>,
    ISubtractionOperators<Meters2, Meters2, Meters2>,
    IAdditiveIdentity<Meters2, Meters2>,
    IComparisonOperators<Meters2, Meters2, bool>,
    IEquatable<Meters2>
{
    public Meters2(double value) => Value = value;
    public double Value { get; }

    public static Meters2 AdditiveIdentity => new(0);

    public static Meters2 operator +(Meters2 a, Meters2 b) => new(a.Value + b.Value);
    public static Meters2 operator -(Meters2 a, Meters2 b) => new(a.Value - b.Value);

    public static bool operator ==(Meters2 a, Meters2 b) => a.Value == b.Value;
    public static bool operator !=(Meters2 a, Meters2 b) => a.Value != b.Value;
    public static bool operator <(Meters2 a, Meters2 b)  => a.Value < b.Value;
    public static bool operator >(Meters2 a, Meters2 b)  => a.Value > b.Value;
    public static bool operator <=(Meters2 a, Meters2 b) => a.Value <= b.Value;
    public static bool operator >=(Meters2 a, Meters2 b) => a.Value >= b.Value;

    public bool Equals(Meters2 other) => Value == other.Value;
    public override bool Equals(object? obj) => obj is Meters2 m && Equals(m);
    public override int GetHashCode() => Value.GetHashCode();
    public override string ToString() => $"{Value} m";
}

public static class GenericSum
{
    public static T Total<T>(IEnumerable<T> values)
        where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
    {
        T total = T.AdditiveIdentity;
        foreach (T value in values)
        {
            total += value;
        }
        return total;
    }

    public static void Demo()
        => Console.WriteLine(Total<Meters2>([new(1.5), new(2.5)]));   // 4 m
}

See Interfaces and Generics.

See Also