TL;DR

  • IEquatable avoids boxing for structs and removes casting overhead for classes.
  • ~35% faster lookups in HashSet<T> and Dictionary<TKey,TValue>.
  • 0 B allocations vs. 1.2 MB when you skip it in value‑type equality checks.
  • Built‑in for C# 9+ record types, no manual plumbing required.

When your HashSet<T> lookups are slow or your value objects don’t compare correctly, you’re probably missing IEquatable<T>. This interface is your key to performance-optimized, type-safe equality comparisons in .NET applications.

Most developers know about Equals(object) and the == operator, but IEquatable<T> often gets overlooked. That’s a mistake, especially when you’re working with collections, value objects, or any scenario where equality checks happen frequently.

What is IEquatable and Why Should You Care?

IEquatable<T> is a generic interface that defines a type-specific equality comparison method. Instead of boxing value types or casting reference types during equality checks, it provides a direct, strongly-typed path to determine if two instances are equal.

public interface IEquatable<T>
{
    bool Equals(T? other);
}

Here’s why it matters:

  • Performance: Avoids boxing for value types and eliminates casting overhead
  • Type Safety: Compile-time checking prevents comparison errors
  • Collection Optimization: HashSet<T>, Dictionary<TKey, TValue>, and List<T>.Contains all use it when available

IEquatable vs Equals vs == Operator: The Key Differences

Here’s a side-by-side comparison showing why IEquatable<T> matters:

// Without IEquatable<T>
public class PersonSlow
{
    public string Name { get; set; }
    public int Age { get; set; }
    
    // Only override Equals(object) - causes boxing and casting
    public override bool Equals(object? obj)
    {
        if (obj is PersonSlow other)
            return Name == other.Name && Age == other.Age;
        return false;
    }
}

// With IEquatable<T>
public class PersonFast : IEquatable<PersonFast>
{
    public string Name { get; set; }
    public int Age { get; set; }
    
    // Type-safe, no boxing or casting
    public bool Equals(PersonFast? other)
    {
        if (other is null) return false;
        return Name == other.Name && Age == other.Age;
    }
    
    // Still need this for non-generic scenarios
    public override bool Equals(object? obj) => Equals(obj as PersonFast);
    
    // Always override GetHashCode when implementing custom equality
    public override int GetHashCode() => HashCode.Combine(Name, Age);
}

Performance Tip: Implement both IEquatable<T>.Equals() and object.Equals() to get the best performance with strongly-typed code while maintaining compatibility with general-purpose APIs.

Benchmark: How Much Faster Is IEquatable in C# Collections?

Our benchmarks reveal significant performance advantages when using IEquatable<T> in real-world collection scenarios.

View the complete IEquatable vs Equals(object) benchmark on GitHub Gist.

| Method                     | Mean     | Error    | StdDev   | Median   | Gen0     | Allocated |
|--------------------------- |---------:|---------:|---------:|---------:|---------:|----------:|
| Contains_WithIEquatable    | 155.1 us |  4.55 us | 12.61 us | 153.3 us |        - |         - |
| Contains_WithoutIEquatable | 210.3 us | 12.32 us | 35.93 us | 199.5 us | 191.1621 | 1200000 B |

Performance Benefits of IEquatable Implementation

Implementing IEquatable<T> delivers two major performance improvements for your C# collections:

First, lookup operations become approximately ~35% faster because the runtime avoids type checking and casting operations. When searching through large collections like HashSet or Dictionary, this speed difference becomes increasingly significant.

Second, memory efficiency improves dramatically. Without IEquatable<T>, the benchmark shows over 1MB of heap allocations and 191 garbage collections. These allocations occur because value types get boxed to object during equality comparisons, creating memory pressure that can degrade application performance.

Why This Matters in Production

This performance difference becomes particularly crucial when working with value types in frequently accessed collections. In high-throughput systems processing millions of lookups, implementing IEquatable<T> can substantially reduce response times and memory consumption.

The memory allocation difference is especially important for long-running applications where garbage collection pauses can impact user experience or service responsiveness.

Equality Method Call Flow


graph LR
    A[Equality Check] --> B{Type Known?}
    B -->|Yes| C{IEquatable&lt;T&gt; Implemented?}
    C -->|Yes| D[Call IEquatable&lt;T&gt;.Equals]
    C -->|No| E[Use == or Equals object]
    B -->|No| E

    D --> F[Fast - No Boxing]
    E --> G[May Box or Cast]



    

Equality Method Call Flow

Implementing IEquatable in Structs vs Classes

Struct Implementation

public struct ProductId : IEquatable<ProductId>
{
    public int Value { get; }
    
    public ProductId(int value) => Value = value;
    
    public bool Equals(ProductId other) => Value == other.Value;
    
    public override bool Equals(object? obj) => obj is ProductId other && Equals(other);
    
    public override int GetHashCode() => Value.GetHashCode();
    
    public static bool operator ==(ProductId left, ProductId right) => left.Equals(right);
    public static bool operator !=(ProductId left, ProductId right) => !left.Equals(right);
}

Class Implementation with Null Safety

public class Customer : IEquatable<Customer>
{
    public string Email { get; set; }
    public string CompanyName { get; set; }
    
    public bool Equals(Customer? other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        return Email == other.Email && CompanyName == other.CompanyName;
    }
    
    public override bool Equals(object? obj) => Equals(obj as Customer);
    
    public override int GetHashCode() => HashCode.Combine(Email, CompanyName);
}

Common Pitfalls and How to Avoid Them

1. Forgetting GetHashCode

// BAD: Missing GetHashCode override
public class BadExample : IEquatable<BadExample>
{
    public string Value { get; set; }
    
    public bool Equals(BadExample? other) => other?.Value == Value;
    // Missing GetHashCode override - will break in HashSet/Dictionary
}

// GOOD: Always pair custom equality with GetHashCode
public class GoodExample : IEquatable<GoodExample>
{
    public string Value { get; set; }
    
    public bool Equals(GoodExample? other) => other?.Value == Value;
    public override int GetHashCode() => Value?.GetHashCode() ?? 0;
}

2. Symmetric Equality Violations

// BAD: Asymmetric equality
public class Parent : IEquatable<Parent>
{
    public string Name { get; set; }
    
    public virtual bool Equals(Parent? other) => other?.Name == Name;
}

public class Child : Parent, IEquatable<Child>
{
    public int Age { get; set; }
    
    // This breaks symmetry: child.Equals(parent) != parent.Equals(child)
    public bool Equals(Child? other) => base.Equals(other) && other?.Age == Age;
}

3. Nullable Reference Type Handling

// Modern C# 12 approach with nullable reference types
public class ModernExample : IEquatable<ModernExample>
{
    public string Name { get; set; } = string.Empty;
    
    public bool Equals(ModernExample? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        
        // Safe string comparison with null handling
        return string.Equals(Name, other.Name, StringComparison.Ordinal);
    }
    
    public override bool Equals(object? obj) => Equals(obj as ModernExample);
    
    public override int GetHashCode() => Name.GetHashCode();
}

Record Types: The Built-In Value Equality Option

If you’re using C# 9 or later, record types give you built-in value equality.
Records automatically implement IEquatable<T>, Equals(object), GetHashCode, and ==/!= operators based on their properties.

This makes them perfect for simple value objects, DTOs, or immutable data carriers.

Example:

public record CustomerRecord(string Email, string CompanyName);

Equivalent to manually implementing:

public class CustomerManual : IEquatable<CustomerManual>
{
    public string Email { get; init; }
    public string CompanyName { get; init; }

    public bool Equals(CustomerManual? other) =>
        other is not null &&
        Email == other.Email &&
        CompanyName == other.CompanyName;

    public override bool Equals(object? obj) => Equals(obj as CustomerManual);

    public override int GetHashCode() => HashCode.Combine(Email, CompanyName);
}

When to Use Records

Use CaseShould You Use Record?
Simple immutable value objectsYes
DTOs or data carriersYes
Complex hierarchies or mutable stateUse classes with IEquatable<T>

Tip: Use record struct if you need a value type with record-like behavior.

Equality Comparison Methods Summary

MethodType SafetyPerformanceBoxing RiskUse Case
IEquatable<T>.Equals(T)StrongFastNoneCollections, known types
Equals(object)RuntimeSlowerValue typesLegacy APIs, unknown types
== operatorStrongFastNoneDirect comparisons
record typesStrongFastNoneValue objects, DTOs

When to Implement IEquatable

Think of IEquatable<T> as telling .NET exactly how two specific types shake hands when meeting. Implement it when:

  • Your objects will be used as keys in dictionaries or stored in hash sets
  • You’re building value objects or DTOs with custom equality semantics
  • Performance matters and you’re doing frequent equality comparisons
  • You want compile-time type safety for equality checks

Skip it when:

  • Reference equality is sufficient (most entity objects)
  • You’re using records for simple value objects
  • The type is only used in scenarios where Equals(object) is acceptable

Key Takeaways

IEquatable<T> isn’t just a performance optimization, it’s a fundamental tool for building robust .NET applications. When you implement it correctly alongside GetHashCode(), you get type-safe, fast equality comparisons that work seamlessly with collections and LINQ operations.

Remember: every time you create a type that needs custom equality logic, ask yourself if it should implement IEquatable<T>. Your future self (and your application’s performance) will thank you.

The next time you see slow collection operations or unexpected equality behavior, check if IEquatable<T> is implemented. It’s often the missing piece that transforms sluggish code into production-ready performance.

Try adding IEquatable<T> to one of your existing value structs today, notice the difference!

References

Further Reading

Related Posts