C# IEquatable<T>: Fast, Type-Safe Equality for Your Types
Equals(object) forces a struct into a box just to compare it. IEquatable<T> exists so it never has to.
You just wrote a correct Equals(object?)/GetHashCode() pair in the previous lesson, and it works perfectly. So why does .NET also define a whole separate interface, IEquatable<T>, that seemingly does the exact same job? The answer only becomes visible when the type doing the comparing is a struct:
public struct Coordinate
{
public double Latitude, Longitude;
public override bool Equals(object? obj) =>
obj is Coordinate c && Latitude == c.Latitude && Longitude == c.Longitude;
public override int GetHashCode() => HashCode.Combine(Latitude, Longitude);
}
var a = new Coordinate { Latitude = 40.7, Longitude = -74.0 };
var b = new Coordinate { Latitude = 40.7, Longitude = -74.0 };
bool areEqual = a.Equals(b); // works correctly — but every call boxes both structs firstThat comparison is correct, but it's quietly wasteful — Equals(object?) takes an object parameter, and passing a struct where an object is expected means boxing it, exactly the heap-allocation overhead you learned to watch for back in the value-vs-reference-types lesson. Every single equality check on this struct pays that cost, even though nothing about comparing two coordinates should ever need the heap.
In this lesson, you'll learn why IEquatable<T> exists specifically to eliminate that boxing, how to implement it correctly alongside Equals and GetHashCode, and why it matters most for value types — though classes benefit too, for different reasons.
What Is It?
The Simple Explanation
IEquatable<T> is a second, more specific way for a type to say "here's how you compare me to another instance of my own exact type" — instead of the general-purpose Equals(object?), which has to accept literally anything and figure out the type at runtime, IEquatable<T>.Equals(T) already knows exactly what type it's comparing against, checked at compile time.
The Technical Definition
IEquatable<T> is a generic interface with exactly one member:
public interface IEquatable<T>
{
bool Equals(T? other);
}This is an overload, not an override — a type implementing IEquatable<T> ends up with two Equals methods side by side: the inherited Equals(object?) (which you should still override, for consistency, exactly as in the previous lesson) and this new, strongly-typed Equals(T?). The compiler picks whichever overload matches the compile-time type of the argument, and for a struct, calling the T-typed overload never requires boxing either argument.
Why Does It Exist?
The Problem
Recall from the value-vs-reference-types lesson: a value type gets boxed the moment it's assigned to something typed as object, and every boxing operation is a heap allocation with real, measurable cost. object.Equals(object?)'s parameter is literally typed object — so calling it on a struct boxes both the receiver and the argument, every single time:
Coordinate a = new() { Latitude = 40.7, Longitude = -74.0 };
Coordinate b = new() { Latitude = 40.7, Longitude = -74.0 };
bool result = a.Equals(b); // without IEquatable<T>: b is boxed to satisfy Equals(object?)
// — a heap allocation, just to run one comparisonNow imagine this comparison running inside a tight loop — deduplicating a million Coordinate values, say, or as the comparison callback for a HashSet<Coordinate>. A million unnecessary boxing allocations is a real, avoidable performance cost, and it's entirely a side effect of Equals(object?)'s parameter type — nothing about comparing two coordinates fundamentally requires the heap.
The Solution
IEquatable<T>.Equals(T? other) takes its parameter as the concrete type T — for Coordinate, that's Equals(Coordinate other) — so neither the receiver nor the argument ever needs to become an object. .NET's own generic collections (List<T>.Contains, Dictionary<TKey,TValue>, HashSet<T>, and more) all check whether T implements IEquatable<T> and, if so, call the strongly-typed overload automatically — meaning implementing this one extra method can make an entire codebase's worth of comparisons on your type boxing-free, without changing any calling code.
Big Picture
a.Equals(b)↓
b is boxed to fit the object parameter↓
heap allocation, every call
a.Equals(b)↓
compiler picks
Equals(Coordinate) — no boxing needed↓
no heap allocation at all
How It Works
public struct Coordinate : IEquatable<Coordinate>
{
public double Latitude, Longitude;
public bool Equals(Coordinate other) =>
Latitude == other.Latitude && Longitude == other.Longitude;
// ...
}
- This is where the real comparison logic lives — no boxing anywhere in this method, since both
thisandotherstay strongly typed asCoordinatethroughout.
public override bool Equals(object? obj) =>
obj is Coordinate other && Equals(other); // reuses the strongly-typed version above
- Still required — plenty of code paths (older APIs, non-generic collections, reflection-based logic) only know about
object.Equals. Delegating avoids duplicating comparison logic in two places, which could otherwise drift out of sync.
public override int GetHashCode() => HashCode.Combine(Latitude, Longitude);
- The Equals/GetHashCode contract from the previous lesson still applies in full —
IEquatable<T>adds a third method to the picture, it doesn't relax the contract between the other two.
List<Coordinate>.Contains,HashSet<Coordinate>, andDictionary<Coordinate, TValue>all detectIEquatable<Coordinate>at their internal comparer level and use it instead of falling back toobject.Equals— no extra code required on your part beyond implementing the interface.
Simple Example
public struct Coordinate : IEquatable<Coordinate>
{
public double Latitude { get; init; }
public double Longitude { get; init; }
public bool Equals(Coordinate other) =>
Latitude == other.Latitude && Longitude == other.Longitude;
public override bool Equals(object? obj) =>
obj is Coordinate other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Latitude, Longitude);
}
var a = new Coordinate { Latitude = 40.7, Longitude = -74.0 };
var b = new Coordinate { Latitude = 40.7, Longitude = -74.0 };
bool viaTyped = a.Equals(b); // compiler picks Equals(Coordinate) — no boxing
object boxed = b;
bool viaObject = a.Equals(boxed); // still works — falls through to Equals(object?), which delegates back
var seen = new HashSet<Coordinate>();
seen.Add(a);
Console.WriteLine(seen.Contains(b)); // True — HashSet<T> uses IEquatable<Coordinate> internally, boxing-freeCode → Meaning → Result:
a.Equals(b)resolves to the strongly-typed overload at compile time, since both sides are known to beCoordinate— no boxing happens.- Explicitly boxing
band callingEquals(object?)still works correctly — it just delegates straight back to the strongly-typed version, so the logic is never duplicated. HashSet<Coordinate>automatically uses the strongly-typedEqualsinternally — the boxing-free path is the default once you've implemented the interface, with zero changes to how you use the collection.
Real-World Example
A location-tracking feature that deduplicates thousands of GPS coordinate readings per second is a realistic scenario where the boxing avoided by IEquatable<T> genuinely matters — not as a micro-optimization nobody would notice, but as the difference between a responsive real-time feature and one that stutters under GC pressure from constant boxing.
public readonly struct GpsReading : IEquatable<GpsReading>
{
public double Latitude { get; init; }
public double Longitude { get; init; }
public DateTime Timestamp { get; init; }
// Two readings are "the same location" if lat/long match — timestamp is deliberately excluded
public bool Equals(GpsReading other) =>
Latitude == other.Latitude && Longitude == other.Longitude;
public override bool Equals(object? obj) => obj is GpsReading other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Latitude, Longitude);
}
public class LocationDeduplicator
{
private readonly HashSet<GpsReading> _seenLocations = new();
// Called potentially thousands of times per second from a live GPS feed
public bool IsNewLocation(GpsReading reading) => _seenLocations.Add(reading);
}
var deduplicator = new LocationDeduplicator();
var reading1 = new GpsReading { Latitude = 40.7128, Longitude = -74.0060, Timestamp = DateTime.UtcNow };
var reading2 = new GpsReading { Latitude = 40.7128, Longitude = -74.0060, Timestamp = DateTime.UtcNow.AddSeconds(1) };
Console.WriteLine(deduplicator.IsNewLocation(reading1)); // True — first time seeing this location
Console.WriteLine(deduplicator.IsNewLocation(reading2)); // False — same lat/long, even though the timestamp differsEvery call to _seenLocations.Add(reading) internally compares the incoming GpsReading against existing entries — without IEquatable<GpsReading>, each of those comparisons would box the struct being compared. At thousands of readings per second, that's thousands of unnecessary heap allocations per second, adding real, avoidable pressure on the garbage collector. Implementing the interface removes that cost entirely.
Analogy
Think of boxing a struct the way you'd think of photocopying a document just to compare it against another one — you didn't need a copy at all, but the comparison process demanded one anyway, so you made one, used it once, and threw it away. Do that a million times and you've wasted a million sheets of paper for no real benefit.
IEquatable<T> is the shortcut that lets you compare the two original documents directly, side by side, with no photocopier involved at all. The comparison itself works identically either way — the only difference is whether you paid the cost of making an unnecessary copy first.
Under the Hood
- A reference type passed as
objectis never boxed — a reference is a reference either way, so there's no heap-allocation cost to avoid. The benefit for classes is different:Equals(T)avoids the runtime type check and cast thatEquals(object?)has to perform (obj is T other), which is a small but real CPU cost the strongly-typed overload skips entirely.
- Internally, .NET's generic collections don't call
.Equals()directly on your elements — they go throughEqualityComparer<T>.Default, which inspectsTonce and picks the fastest available comparison strategy:IEquatable<T>if present, falling back to boxedobject.Equalsonly if it isn't. This is exactly the mechanism that makes implementing the interface transparently speed up every collection operation, with no other code changes.
- Just as with
Equals(object?)andGetHashCode(), bothrecordandrecord structautomatically generate a correctIEquatable<T>implementation as part of their synthesized equality members — one more reason records are the default recommendation whenever a type is fundamentally data.
Common Confusion
1. Equals(T) and Equals(object?) are two separate methods, not one overriding the other
A common mistake is thinking IEquatable<T>.Equals(T) "replaces" object.Equals(object?) the way an override would. It doesn't — they coexist as two overloads of the same method name, and the compiler picks whichever one matches the compile-time type of the argument. That's exactly why you still need to override Equals(object?) separately, and why delegating it to Equals(T) is the recommended pattern.
2. Implementing IEquatable<T> alone doesn't fix hash-based collections — GetHashCode still has to be right
It's tempting to think adding IEquatable<T> is a complete equality solution on its own. It isn't — the contract from the previous lesson (equal objects must produce equal hash codes) still applies in full, regardless of how many Equals overloads exist. All three methods — Equals(T), Equals(object?), and GetHashCode() — need to agree with each other.
3. The boxing benefit is specifically a value-type story — don't expect the same magnitude of gain on a class
As covered in "Under the Hood," classes never boxed in the first place, so IEquatable<T>'s benefit there is a smaller type-check/cast savings, not a heap-allocation savings. The dramatic performance story — avoiding a heap allocation on every comparison — is specific to structs, which is exactly why this lesson leaned on Coordinate and GpsReading as its examples.
Common Mistakes
Mistake 1 — Implementing Equals(T) and Equals(object?) with inconsistent logic
Wrong — the two methods disagree, which is confusing and can produce different results depending on which overload happens to be called:
public bool Equals(Coordinate other) => Latitude == other.Latitude && Longitude == other.Longitude;
public override bool Equals(object? obj) => obj is Coordinate c && Latitude == c.Latitude; // forgot Longitude! Correct — always have Equals(object?) delegate to Equals(T), so there's exactly one source of truth:
public override bool Equals(object? obj) => obj is Coordinate other && Equals(other);Mistake 2 — Implementing IEquatable<T> but forgetting GetHashCode
This reintroduces exactly the contract violation from the previous lesson — a struct can be "equal" via IEquatable<T> while still hashing inconsistently, breaking HashSet<T> and dictionary lookups in the same silent way. All three methods — Equals(T), Equals(object?), GetHashCode() — are a package deal; implement all three together.
Mistake 3 — Assuming IEquatable<T> matters equally for every type
Chasing this optimization on a reference type that's rarely compared, or on a struct that's rarely used in hot paths or large collections — added complexity for negligible real benefit. Prioritize implementing it on struct types that will realistically be compared frequently, especially inside collections or tight loops, exactly like the GPS example.
When Should I Use It?
Implement IEquatable<T> when
- You're defining a
structthat will realistically be compared often — used as a dictionary key, stored in aHashSet<T>, or compared inside a hot loop. - You're already overriding
Equals/GetHashCodeon a class — addingIEquatable<T>too is a small, worthwhile addition for the type-check savings.
Don't bother when
- You're using a
recordorrecord struct— you already get this for free. - The type is rarely, if ever, compared for equality — there's no meaningful cost to avoid.
struct that will live in a collection or be compared frequently, treat IEquatable<T> as part of the same package as Equals/GetHashCode — implement all three together, with Equals(object?) simply delegating to Equals(T).
Mental Model
IEquatable<T>.Equals(T?) = the strongly-typed overload — no boxing needed for a struct
EqualityComparer<T>.Default = what collections actually use — picks IEquatable<T> automatically when available
Remember:
· Implement all three together:
Equals(T), Equals(object?) delegating to it, and GetHashCode().· The boxing savings is a struct story specifically — classes benefit from a smaller type-check savings instead.
· Records and record structs already implement this correctly — no manual work needed.
Key Takeaway
IEquatable<T>adds a strongly-typedEquals(T?)overload alongsideobject.Equals(object?)— for astruct, this avoids boxing on every comparison.- Always delegate
Equals(object?)toEquals(T), so comparison logic exists in exactly one place. - .NET's generic collections use
EqualityComparer<T>.Defaultinternally, which automatically prefersIEquatable<T>when it's available — implementing it speeds up every collection operation on that type transparently. - Classes benefit too, though more modestly — skipping the type-check-and-cast that
Equals(object?)requires. - Records and record structs generate a correct
IEquatable<T>implementation automatically, alongside theEquals/GetHashCodepair from the previous lesson.
Check Your Understanding
You've learned why IEquatable<T> exists specifically to avoid boxing. Let's check your understanding.
1. Why does calling Equals(object?) on a struct involve a heap allocation, while IEquatable<T>.Equals(T?) does not?
Show answer
Correct: B
Why B is correct: As explained in "Why Does It Exist?", boxing happens specifically because object-typed parameters force a value type onto the heap — IEquatable<T>'s strongly-typed parameter sidesteps that requirement entirely.
Why A is incorrect: This isn't an implementation quality issue — it's an inherent consequence of the parameter type being object versus T.
Why C is incorrect: Structs are stack-allocated (or embedded within their containing object) by default — boxing is specifically what forces a struct onto the heap, and it only happens when required, such as being passed as object.
Why D is incorrect: IEquatable<T> has no effect on garbage collection settings — it simply avoids generating garbage (a boxed copy) in the first place.
Reinforcement: The boxing cost comes directly from the parameter type of the method being called, not from anything inherent to comparing structs in general.
2. A type implements IEquatable<T>.Equals(T) but its Equals(object?) override uses different comparison logic that checks fewer fields. What problem does this create?
Show answer
Correct: B
Why B is correct: As covered in "Common Mistakes," having two independently-written comparison implementations invites exactly this kind of drift — the fix is always delegating Equals(object?) to Equals(T), so there's only one source of truth.
Why A is incorrect: While technically legal to compile, this is a real correctness bug — callers reasonably expect one consistent notion of "equal" regardless of which overload gets selected.
Why C is incorrect: Both methods can be implemented independently without any compile error — the problem is a logical inconsistency, not a syntax error.
Why D is incorrect: As covered in "Common Confusion," the two are separate overloads, not an override relationship — implementing one does not automatically affect the other.
Reinforcement: Always keep exactly one source of truth for equality logic by having Equals(object?) delegate to Equals(T).
3. Why does implementing IEquatable<T> on a type automatically speed up operations like HashSet<T>.Contains, without changing any code that calls Contains?
Show answer
Correct: B
Why B is correct: As explained in "Under the Hood," EqualityComparer<T>.Default is the mechanism collections actually use for comparisons — it inspects T once and automatically prefers the strongly-typed, boxing-free path whenever IEquatable<T> is present.
Why A is incorrect: No code rewriting happens — the selection logic lives inside EqualityComparer<T>.Default, evaluated once, not by regenerating Contains itself.
Why C is incorrect: Equals is still very much used — just the faster, strongly-typed overload instead of the boxed one.
Why D is incorrect: This is precisely backwards — the whole point of this lesson is that implementing IEquatable<T> transparently improves collection performance for value types.
Reinforcement: EqualityComparer<T>.Default is the hidden mechanism that makes implementing one interface improve performance everywhere that type is compared, without touching any calling code.
You now understand equality, hashing, ordering, and how to avoid boxing on comparisons — the full toolkit for well-behaved custom types. Next: the capstone of this module — building your own collection type from scratch with IEnumerable<T> and yield return, tying generics, iteration, and equality together.