Fundamentals - Part 17

12/13/2025
4 minute read

When should you add custom equality?

  • Value objects: types that represent a concept by value (Money, DateRange, Email, Coordinates). Two instances with the same components should be equal.
  • Tiny wrappers: IDs or strongly typed strings/ints (UserId, Sku) so you do not mix them up; equality prevents multiple wrappers around the same value from behaving differently.
  • Structs: the default field-by-field comparison may be fine, but implement explicitly when you have invariants or want faster IEquatable<T> to avoid boxing.
  • Records: they already get value equality; customize only when you need to exclude fields from comparison or add domain-specific rules.
  • Not usually: entities tracked by identity (database row with generated key), large mutable graphs, or types where reference identity conveys lifecycle.

The equality contract to honor

  • Reflexive: x.Equals(x) is true.
  • Symmetric: x.Equals(y) == y.Equals(x).
  • Transitive: if x == y and y == z, then x == z.
  • Consistent: equal objects must return the same hash code.
  • Null-safe: x.Equals(null) is false; operators handle null without throwing.

A correct pattern for reference types

public sealed 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));
    }

    public bool Equals(Money? other)
    {
        if (ReferenceEquals(this, other))
        {
            return true;
        }

        if (other is null)
        {
            return false;
        }

        return Amount == other.Amount && Currency == other.Currency;
    }

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

    public override int GetHashCode() => HashCode.Combine(Amount, Currency);

    public static bool operator ==(Money? left, Money? right) =>
        Equals(left, right);

    public static bool operator !=(Money? left, Money? right) => !(left == right);
}
  • Implement IEquatable<T> for speed (avoids boxing) and clarity.
  • Delegate Equals(object) to the typed overload.
  • Use HashCode.Combine (or System.HashCode accumulator) to keep hashes aligned with the fields used in equality.
  • Provide both == and != together or neither; keep them consistent with Equals.

Structs: prefer readonly and IEquatable<T>

public readonly struct Point : IEquatable<Point>
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y) => (X, Y) = (x, y);

    public bool Equals(Point other)
    {
        return X == other.X && Y == other.Y;
    }

    public override bool Equals(object? obj) => obj is Point p && Equals(p);

    public override int GetHashCode() => HashCode.Combine(X, Y);

    public static bool operator ==(Point left, Point right) => left.Equals(right);
    public static bool operator !=(Point left, Point right) => !left.Equals(right);
}
  • readonly struct keeps fields immutable, making hash codes stable.
  • Do not rely on mutable fields for hash-based collections; mutation after insertion can break lookup.

Records, tuples, and anonymous types

  • record and record struct emit value equality across positional and property members by default.
  • Value tuples and anonymous types already compare structurally; they also implement IEquatable<T>.
  • Override Equals/GetHashCode on a record only when you need to ignore a property (e.g., transient CalculatedTax).

Collections: choose the comparer deliberately

  • Sequences: use Enumerable.SequenceEqual(a, b, comparer) for order-sensitive comparisons.
  • Sets: HashSet<T> and ImmutableHashSet<T> rely on the element comparer; if elements need custom rules, supply IEqualityComparer<T>.
  • Dictionaries: choose the key comparer (case-insensitive strings, culture-aware rules) via Dictionary<TKey, TValue>(comparer) or ImmutableDictionary.Create(comparer).
  • Arrays: StructuralComparisons.StructuralEqualityComparer compares elements, but SequenceEqual is usually clearer.

Example comparer for collections

public sealed class PersonIdComparer : IEqualityComparer<Person>
{
    public bool Equals(Person? x, Person? y)
    {
        if (ReferenceEquals(x, y))
        {
            return true;
        }

        if (x is null || y is null)
        {
            return false;
        }

        return x.Id == y.Id;
    }

    public int GetHashCode(Person obj) => obj.Id.GetHashCode();
}

var set = new HashSet<Person>(new PersonIdComparer());
  • Custom comparers decouple collection behavior from the type’s own equality; use them when the collection needs a different notion of “same”.

Testing your implementation

  • Verify hash code stability by inserting into HashSet<T> and confirming lookups before and after operations that should not change equality.
  • Add round-trip tests: objects that are equal should serialize/deserialize to an equal instance.
  • Include negative cases (different currency, different order in sequences) to ensure the comparer is strict enough.

Final thoughts

  • Decide up front whether a type is identity-first or value-first; equality follows that decision.
  • Implement IEquatable<T>, override Equals and GetHashCode, and keep ==/!= aligned.
  • For collections, pass explicit comparers so sets and dictionaries reflect the rules you intend.
An error has occurred. This application may no longer respond until reloaded. Reload x