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.
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.
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.
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.
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.
a.Equals(b)b is boxed to fit the object parametera.Equals(b)Equals(Coordinate) — no boxing neededpublic struct Coordinate : IEquatable<Coordinate>
{
public double Latitude, Longitude;
public bool Equals(Coordinate other) =>
Latitude == other.Latitude && Longitude == other.Longitude;
// ...
}
this and other stay strongly typed as Coordinate throughout.public override bool Equals(object? obj) =>
obj is Coordinate other && Equals(other); // reuses the strongly-typed version above
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);
IEquatable<T> adds a third method to the picture, it doesn't relax the contract between the other two.List<Coordinate>.Contains, HashSet<Coordinate>, and Dictionary<Coordinate, TValue> all detect IEquatable<Coordinate> at their internal comparer level and use it instead of falling back to object.Equals — no extra code required on your part beyond implementing the interface.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 be Coordinate — no boxing happens.b and calling Equals(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-typed Equals internally — the boxing-free path is the default once you've implemented the interface, with zero changes to how you use the collection.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.
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.
object is 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 that Equals(object?) has to perform (obj is T other), which is a small but real CPU cost the strongly-typed overload skips entirely..Equals() directly on your elements — they go through EqualityComparer<T>.Default, which inspects T once and picks the fastest available comparison strategy: IEquatable<T> if present, falling back to boxed object.Equals only 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.Equals(object?) and GetHashCode(), both record and record struct automatically generate a correct IEquatable<T> implementation as part of their synthesized equality members — one more reason records are the default recommendation whenever a type is fundamentally data.Equals(T) and Equals(object?) are two separate methods, not one overriding the otherA 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.
IEquatable<T> alone doesn't fix hash-based collections — GetHashCode still has to be rightIt'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.
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.
Equals(T) and Equals(object?) with inconsistent logicWrong — 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);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.
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.
struct that will realistically be compared often — used as a dictionary key, stored in a HashSet<T>, or compared inside a hot loop.Equals/GetHashCode on a class — adding IEquatable<T> too is a small, worthwhile addition for the type-check savings.record or record struct — you already get this for free.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).
Equals(T), Equals(object?) delegating to it, and GetHashCode().IEquatable<T> adds a strongly-typed Equals(T?) overload alongside object.Equals(object?) — for a struct, this avoids boxing on every comparison.Equals(object?) to Equals(T), so comparison logic exists in exactly one place.EqualityComparer<T>.Default internally, which automatically prefers IEquatable<T> when it's available — implementing it speeds up every collection operation on that type transparently.Equals(object?) requires.IEquatable<T> implementation automatically, alongside the Equals/GetHashCode pair from the previous lesson.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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.