TL;DR
- IEquatable avoids boxing for structs and removes casting overhead for classes.
- ~35% faster lookups in
HashSet<T>
andDictionary<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>
, andList<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()
andobject.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<T> Implemented?} C -->|Yes| D[Call IEquatable<T>.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 Case | Should You Use Record? |
---|---|
Simple immutable value objects | Yes |
DTOs or data carriers | Yes |
Complex hierarchies or mutable state | Use classes with IEquatable<T> |
Tip: Use
record struct
if you need a value type with record-like behavior.
Equality Comparison Methods Summary
Method | Type Safety | Performance | Boxing Risk | Use Case |
---|---|---|---|---|
IEquatable<T>.Equals(T) | Strong | Fast | None | Collections, known types |
Equals(object) | Runtime | Slower | Value types | Legacy APIs, unknown types |
== operator | Strong | Fast | None | Direct comparisons |
record types | Strong | Fast | None | Value 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
- SortedList vs SortedDictionary vs Lookup in C#: Differences & Use Cases
- C# 14's params for Collections: Say Goodbye to Arrays!
- Why You Should Avoid ArrayList in Modern C#
- Avoiding Boxing with Struct Dictionary Keys in C#: Performance and Best Practices
- Building Custom Collection Types in C#: IEnumerable, Indexers, and Domain Logic