Structural Equality in C# Tuples and IEquatable

Structural equality ensures that two objects are considered equal if their data or contents match, regardless of whether they share the exact same location in memory. In C#, value tuples provide built-in structural equality out of the box, whereas custom classes and structs rely on reference equality or inefficient reflection by default. By implementing the IEquatable<T> interface, developers can define fast, type-safe, and customized structural equality for their domain models and data structures.

How Structural Equality Works in Tuples

C# distinguishes between modern value tuples (System.ValueTuple) and older reference tuples (System.Tuple). Most modern C# development utilizes ValueTuple, represented by the concise syntax (x, y).

Value tuples implement structural equality natively. When you compare two value tuples using the == operator or the .Equals() method, the runtime evaluates the elements sequentially from left to right:

var pointA = (X: 1, Y: 2);
var pointB = (X: 1, Y: 2);

bool areEqual = pointA == pointB; // Returns true

Under the hood, ValueTuple implements both IEquatable<T> and IStructuralEquatable. The comparison behaves as follows:

  • Element-by-Element Comparison: Each corresponding item in the tuple is evaluated using EqualityComparer<T>.Default.
  • Short-Circuiting: The comparison returns false at the first mismatched element without evaluating subsequent items.
  • Name Agnostic: Member names are erased at compile time. Only element positions, types, and values determine equality. For instance, (A: 1, B: 2) == (X: 1, Y: 2) evaluates to true.

For older reference types (System.Tuple), the == operator performs a standard reference check. To achieve structural comparison with System.Tuple, you must explicitly invoke the IStructuralEquatable.Equals method passing an IEqualityComparer.

Structural Equality with Custom Types and IEquatable<T>

Classes in C# use reference equality by default, meaning two distinct instances with identical properties evaluate as unequal. Structs provide value-based equality by default, but the runtime relies on reflection via ValueType.Equals, which introduces significant performance overhead and causes boxing.

Implementing IEquatable<T> enables clean, performant, and explicit structural equality.

The Role of IEquatable<T>

The IEquatable<T> interface exposes a single method: bool Equals(T other). Implementing this interface provides two primary benefits:

  1. Performance: It removes boxing and unboxing penalties when comparing value types.
  2. Type Safety: It bypasses casting and runtime type-checking requirements present in the legacy object.Equals(object obj) method.

Collections like List<T>, Dictionary<TKey, TValue>, and various LINQ operators automatically look for IEquatable<T> when executing methods like Contains or IndexOf.

Complete Implementation Pattern

To achieve robust structural equality for a custom type, implementing IEquatable<T> must be paired with overriding Object.Equals, overriding Object.GetHashCode, and overloading the equality operators (== and !=).

public sealed class Person : IEquatable<Person>
{
    public string FirstName { get; }
    public string LastName { get; }

    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }

    // 1. Strongly typed IEquatable<T> implementation
    public bool Equals(Person? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;

        return string.Equals(FirstName, other.FirstName, StringComparison.Ordinal) &&
               string.Equals(LastName, other.LastName, StringComparison.Ordinal);
    }

    // 2. Override Object.Equals
    public override bool Equals(object? obj)
    {
        return Equals(obj as Person);
    }

    // 3. Override GetHashCode to maintain the equality contract
    public override int GetHashCode()
    {
        return HashCode.Combine(
            StringComparer.Ordinal.GetHashCode(FirstName),
            StringComparer.Ordinal.GetHashCode(LastName)
        );
    }

    // 4. Overload == and != operators
    public static bool operator ==(Person? left, Person? right)
    {
        if (left is null) return right is null;
        return left.Equals(right);
    }

    public static bool operator !=(Person? left, Person? right)
    {
        return !(left == right);
    }
}

Critical Rules for Structural Equality

When working with structural equality across both tuples and custom types, ensure the following contracts are maintained:

  • The Hash Code Contract: If two objects are structurally equal via Equals, their GetHashCode() methods must return the same integer. If this rule is broken, types will fail when stored in hash-based collections like HashSet<T> or Dictionary<TKey, TValue>.
  • Immutability: Types used for structural comparisons should ideally be immutable. Mutating an object after inserting it into a hash-based collection alters its hash code, corrupting the collection lookup.
  • Modern Alternative (Records): For data-centric types where boilerplate code is undesirable, C# record class and record struct automatically synthesize IEquatable<T>, equality operator overloads, and property-based hash code generation identical to this manual pattern.