← Open in the full interactive course (progress tracking, search & more)

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 first

That 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 comparison

Now 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

WITHOUT IEquatable<T> vs WITH IEquatable<T> (STRUCT)
Without — Equals(object?) only
a.Equals(b)

b is boxed to fit the object parameter

heap allocation, every call
With — IEquatable<Coordinate>
a.Equals(b)

compiler picks Equals(Coordinate) — no boxing needed

no heap allocation at all

How It Works

IMPLEMENTING IEquatable<T> CORRECTLY, STEP BY STEP
1. IMPLEMENT THE STRONGLY-TYPED Equals(T?)
public struct Coordinate : IEquatable<Coordinate>
{
    public double Latitude, Longitude;

    public bool Equals(Coordinate other) =>
        Latitude == other.Latitude && Longitude == other.Longitude;
    // ...
}
2. OVERRIDE Equals(object?) TO DELEGATE TO IT
public override bool Equals(object? obj) =>
    obj is Coordinate other && Equals(other);   // reuses the strongly-typed version above
3. OVERRIDE GetHashCode() — SAME RULES AS THE PREVIOUS LESSON
public override int GetHashCode() => HashCode.Combine(Latitude, Longitude);
4. GENERIC COLLECTIONS PICK UP THE BOXING-FREE PATH AUTOMATICALLY

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-free

Code → Meaning → Result:

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 differs

Every 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

A Photocopy vs the Original Document

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

WHY REFERENCE TYPES BENEFIT TOO — JUST DIFFERENTLY
1. CLASSES DON'T BOX — SO WHAT'S THE BENEFIT THERE?
2. GENERIC COLLECTIONS CHECK FOR IEquatable<T> VIA EqualityComparer<T>.Default
3. RECORDS AND RECORD STRUCTS IMPLEMENT IT FOR YOU

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

Don't bother when

Rule of thumb: For a hand-written 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

Equals(object?) = the general-purpose overload — boxes a struct to compare it
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.