TL;DR: .NET 11 adds EqualityComparer<T>.Create(keySelector, keyComparer). Comparing objects by one property in a HashSet, Dictionary, or LINQ set operator is now a lambda, not a class. .NET 11 is at Release Candidate 2 right now: go-live licensed, but not final yet.
The class nobody wants to write again
You have a list of products. Two of them have the same SKU, and you want them treated as the same thing. So you open a new file and write this:
sealed class ProductBySkuComparer : IEqualityComparer<Product>
{
public bool Equals(Product? x, Product? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
return string.Equals(x.Sku, y.Sku, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(Product obj) =>
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Sku);
}
Twelve lines to say "compare by Sku". And there are two places to get it wrong: forget the case-insensitive hash, and Equals says yes while the HashSet never looks in the right bucket. It compiles fine and fails quietly, which is the worst kind of bug.
.NET 8 helped a little with EqualityComparer<T>.Create(equals, getHashCode). You still had to write both halves and keep them in sync.
Meet the key selector overload
.NET 11 adds a second Create that takes the key instead of the logic:
public static EqualityComparer<T> Create<TKey>(
Func<T?, TKey?> keySelector,
IEqualityComparer<TKey>? keyComparer = default);
You say which property matters. Equality and hashing both come from that one key, so they cannot drift apart.
The minimal example
var bySku = EqualityComparer<Product>.Create(p => p?.Sku);
var set = new HashSet<Product>(bySku)
{
new("A-1", "Mug"),
new("B-2", "Hat"),
new("A-1", "Mug v2"),
};
Console.WriteLine(set.Count); // 2
foreach (var p in set) Console.WriteLine($"{p.Sku} {p.Name}");
// A-1 Mug
// B-2 Hat
record Product(string Sku, string Name);
The first A-1 wins, and Mug v2 is turned away at the door.
Need the key compared a specific way? Pass a comparer for the key itself:
var bySkuIgnoreCase = EqualityComparer<Product>.Create(
p => p?.Sku, StringComparer.OrdinalIgnoreCase);
Console.WriteLine(bySkuIgnoreCase.Equals(
new("A-1", "Mug"), new("a-1", "Mug v2"))); // True
That one line replaces the whole class at the top of this post.
A realistic example: two customer lists, one person
Your CRM and your shop both have customers. Same humans, different IDs, and nobody agrees on how to capitalize an email address.
Customer[] crm =
[
new(1, "Ana@Example.com", "Ana"),
new(2, "bo@example.com", "Bo"),
];
Customer[] shop =
[
new(77, "ana@example.com", "Ana P."),
new(78, "cy@example.com", "Cy"),
];
var byEmail = EqualityComparer<Customer>.Create(
c => c?.Email, StringComparer.OrdinalIgnoreCase);
// Everyone, once
var everyone = new HashSet<Customer>(crm, byEmail);
everyone.UnionWith(shop);
Console.WriteLine(everyone.Count); // 3
// Who is new in the shop
var newInShop = shop.Except(crm, byEmail).Select(c => c.Email);
Console.WriteLine(string.Join(", ", newInShop)); // cy@example.com
// Orders per person, keyed by the customer object
var orders = new Dictionary<Customer, int>(byEmail);
foreach (var c in crm.Concat(shop))
orders[c] = orders.GetValueOrDefault(c) + 1;
foreach (var (c, n) in orders) Console.WriteLine($"{c.Email}: {n}");
// Ana@Example.com: 2
// bo@example.com: 1
// cy@example.com: 1
record Customer(int Id, string Email, string Name);
One comparer, three different APIs, and Ana is counted once. The dictionary keeps the first key it saw (Ana@Example.com) and just bumps the value.
Can I use it?
The key selector overload is new in .NET 11. On the .NET 10 SDK, the same line does not compile:
error CS1593: Delegate 'Func<P?, P?, bool>' does not take 1 arguments
That is the compiler finding only the .NET 8 overload and telling you it wanted two arguments.
Everything in this post was run as a file-based app on the .NET 11 RC 1 SDK. RC 2 is the current release candidate, and it carries a go-live license.
Gotchas
1. The key selector gets null. The parameter is Func<T?, TKey?>, and the comparer really does call it with null. Write p => p.Sku and you get warning CS8602, then a NullReferenceException the first time null is compared. Write p => p?.Sku and it behaves: Equals(null, null) is true, Equals(null, someone) is false, and GetHashCode(null) returns 0.
2. The old overload still bites. Create((a, b) => ...) with no hash delegate compiles fine. Put it in a HashSet and the first Add throws NotSupportedException, because there is no hash code to ask for. The key selector overload does not have this trap.
3. The key must not change. Hash sets put an item in a bucket based on its key at insert time.
var tickets = new HashSet<Ticket>(EqualityComparer<Ticket>.Create(t => t?.Code));
var t1 = new Ticket { Code = "X1" };
tickets.Add(t1);
t1.Code = "X2";
Console.WriteLine(tickets.Contains(t1)); // False
class Ticket { public string Code { get; set; } = ""; }
Same object, same set, and the set swears it has never seen it. Use keys that never change, like IDs or init properties.
4. You might not need a comparer at all. For a one-off LINQ query, DistinctBy, ExceptBy, and UnionBy already take a key selector. The new Create is for when the comparer has to live somewhere: a HashSet, a Dictionary, or a field you pass around.
Wrap-up
If your codebase has a folder full of XxxByIdComparer classes, most of them are now one line each. Grab the .NET 11 RC SDK and delete one this week.
Sources
- What's new in .NET 11 libraries: EqualityComparer<T>.Create
- EqualityComparer<T>.Create API reference
- Announcing .NET 11 Release Candidate 2
This article was written with the help of AI and checked against the sources above.
Top comments (0)