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 trueUnder 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
falseat 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 totrue.
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:
- Performance: It removes boxing and unboxing penalties when comparing value types.
- 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, theirGetHashCode()methods must return the same integer. If this rule is broken, types will fail when stored in hash-based collections likeHashSet<T>orDictionary<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 classandrecord structautomatically synthesizeIEquatable<T>, equality operator overloads, and property-based hash code generation identical to this manual pattern.