DEV Community

Cover image for Custom Comparers Inside Collection Expressions: The Tiny C# Feature That Makes Sets Feel Complete
Sukhpinder Singh
Sukhpinder Singh

Posted on

Custom Comparers Inside Collection Expressions: The Tiny C# Feature That Makes Sets Feel Complete

There is a specific kind of code that feels small but annoying every time you write it.

Not broken.
Not complicated.
Just… slightly clunky.

For me, one of those tiny annoyances has always been creating a collection with a custom comparer.

A normal collection expression looks beautiful:

List<string> languages = ["C#", "F#", "TypeScript"];
Enter fullscreen mode Exit fullscreen mode

Clean. Obvious. No ceremony.

C# 12 introduced collection expressions as a short syntax for creating common collection values, including arrays, spans, lists, and other collection-like types. It also added the spread element, .., for inlining one collection inside another. (Microsoft Learn)

So far, so good.

But then you need a HashSet<string> that treats "dotnet", "DOTNET", and "DotNet" as the same value.

Suddenly the elegance disappears.

var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    "dotnet",
    "DOTNET",
    "DotNet"
};
Enter fullscreen mode Exit fullscreen mode

This code is fine. It works. We have all written it.

But once you get used to collection expressions, this starts to feel like going back to an older dialect of C# just because your collection needs a comparer.

That is where collection expression arguments come in.

As of C# 15 preview, Microsoft documents a new with(...) element that can be placed as the first item in a collection expression. It lets you pass arguments to the underlying collection constructor or factory method, including capacity values, comparers, and other constructor parameters. (Microsoft Learn)

Here is the idea:

HashSet<string> tags =
[
    with(StringComparer.OrdinalIgnoreCase),
    "dotnet",
    "DOTNET",
    "DotNet"
];

Console.WriteLine(tags.Count); // 1
Enter fullscreen mode Exit fullscreen mode

That first line inside the brackets is not an item in the set.

It is not adding StringComparer.OrdinalIgnoreCase to the collection.

It is saying:

“Create this collection with this comparer, then add these elements.”

And that tiny shift makes the code read much closer to what we mean.


The Problem: A Set Is Not Just a Bag of Values

When we create a HashSet<T>, we usually care about uniqueness.

But uniqueness depends on comparison rules.

These three strings are different under the default comparison:

"api"
"API"
"Api"
Enter fullscreen mode Exit fullscreen mode

But in many real applications, they represent the same thing.

API tags.
Email addresses.
Usernames.
Product codes.
Route names.
Country codes.

In those cases, the comparer is not an implementation detail. It is part of the meaning of the collection.

Before this new syntax, collection expressions gave us the values nicely:

HashSet<string> tags = ["api", "API", "Api"];
Enter fullscreen mode Exit fullscreen mode

But they did not let us configure the set itself.

So the moment we needed a comparer, we had to step outside the expression:

var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    "api",
    "API",
    "Api"
};
Enter fullscreen mode Exit fullscreen mode

Again, not terrible.

But it splits the story.

The constructor says how equality works.
The initializer says what values exist.
The syntax feels like two separate ideas.

With with(...), they sit together:

HashSet<string> tags =
[
    with(StringComparer.OrdinalIgnoreCase),
    "api",
    "API",
    "Api"
];
Enter fullscreen mode Exit fullscreen mode

Microsoft’s own example shows this exact pattern: a HashSet<string> using StringComparer.OrdinalIgnoreCase, where the set contains only one element because the strings are equal under that comparer. (Microsoft Learn)

That is the whole feature in one sentence:

Collection expressions can now describe both the contents and the construction behavior of the collection.


A More Realistic Example: Comparing Users by Email

Let’s say we have users coming from different systems.

One system sends:

ALEX@EXAMPLE.COM
Enter fullscreen mode Exit fullscreen mode

Another sends:

alex@example.com
Enter fullscreen mode Exit fullscreen mode

To a database or identity system, those may represent the same user.

Here is a small model:

public sealed record User(string Email, string DisplayName);
Enter fullscreen mode Exit fullscreen mode

Now we create a custom comparer:

public sealed class UserEmailComparer : IEqualityComparer<User>
{
    public bool Equals(User? x, User? y)
    {
        return StringComparer.OrdinalIgnoreCase.Equals(x?.Email, y?.Email);
    }

    public int GetHashCode(User obj)
    {
        return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Email);
    }
}
Enter fullscreen mode Exit fullscreen mode

Without collection expression arguments, we would write this:

var users = new HashSet<User>(new UserEmailComparer())
{
    new("ALEX@EXAMPLE.COM", "Alex"),
    new("alex@example.com", "Alexander"),
    new("sam@example.com", "Sam")
};
Enter fullscreen mode Exit fullscreen mode

With the new syntax:

HashSet<User> users =
[
    with(new UserEmailComparer()),
    new("ALEX@EXAMPLE.COM", "Alex"),
    new("alex@example.com", "Alexander"),
    new("sam@example.com", "Sam")
];

Console.WriteLine(users.Count); // 2
Enter fullscreen mode Exit fullscreen mode

This reads beautifully.

Create a set with this equality rule.
Then add these users.

The comparer is right where your eyes expect it to be: before the values that depend on it.


Why with(...) Has to Come First

This part matters.

The with(...) element must be the first element in the collection expression. The arguments inside it are passed to the appropriate constructor or create method based on the target type. (Microsoft Learn)

So this is valid:

HashSet<string> tags =
[
    with(StringComparer.OrdinalIgnoreCase),
    "csharp",
    "CSHARP"
];
Enter fullscreen mode Exit fullscreen mode

But this is not:

HashSet<string> tags =
[
    "csharp",
    with(StringComparer.OrdinalIgnoreCase),
    "CSHARP"
];
Enter fullscreen mode Exit fullscreen mode

That rule is actually a good thing.

The comparer changes the behavior of the collection. You should not have to scan to the middle or bottom of a long collection expression to discover that equality works differently.

The feature specification even calls out this motivation: placing with(...) first keeps important construction behavior visible before the collection contents. (Microsoft Learn)

That makes the code easier to review.

And honestly, easier to trust.


It Is Not Only for HashSet<T>

Custom comparers are easiest to explain with HashSet<T>, but the idea is broader.

The same syntax can pass constructor arguments such as list capacity:

string[] values = ["one", "two", "three"];

List<string> names =
[
    with(capacity: values.Length * 2),
    .. values
];
Enter fullscreen mode Exit fullscreen mode

The C# 15 docs show this exact kind of example: passing a capacity argument to List<T> and a comparer argument to HashSet<T>. (Microsoft Learn)

You can also imagine sorted collections where the comparer defines ordering:

SortedSet<string> names =
[
    with(StringComparer.OrdinalIgnoreCase),
    "zoe",
    "Ana",
    "ana"
];
Enter fullscreen mode Exit fullscreen mode

In this case, the comparer is not just about duplicate detection. It also affects ordering.

That is an important mindset shift:

A comparer is not “extra setup.”
It is part of the collection’s identity.


Target Typing Still Matters

Collection expressions depend on a target type.

This is clear:

HashSet<string> tags =
[
    with(StringComparer.OrdinalIgnoreCase),
    "api",
    "API"
];
Enter fullscreen mode Exit fullscreen mode

The compiler knows it needs to build a HashSet<string>.

But this is not the kind of thing you should expect to work magically:

var tags =
[
    with(StringComparer.OrdinalIgnoreCase),
    "api",
    "API"
];
Enter fullscreen mode Exit fullscreen mode

There is not enough information in that expression alone to know what collection type you want.

Is it a HashSet<string>?
A SortedSet<string>?
Some custom collection with a builder?

Be explicit when the construction behavior matters.

That is not a weakness of the feature. It is a readability win.

HashSet<string> tags =
[
    with(StringComparer.OrdinalIgnoreCase),
    "api",
    "API"
];
Enter fullscreen mode Exit fullscreen mode

Anyone reading this knows exactly what is being created and how equality works.


A Small Warning: This Is C# 15 Preview

As of Microsoft’s current documentation, C# 15 is a preview release supported by .NET 11 preview versions, and collection expression arguments are listed as a C# 15 feature. (Microsoft Learn)

So before you paste this into production code, check your project’s language version and SDK.

You may need something like:

<PropertyGroup>
    <LangVersion>preview</LangVersion>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

Use preview features carefully, especially in libraries or long-lived enterprise projects.

The syntax is exciting, but your build pipeline has to agree.


A Few Rules Worth Remembering

The with(...) element is simple, but it is not unlimited.

According to the C# collection expressions documentation:

  • with(...) must be the first element.
  • The arguments cannot have dynamic type.
  • It is not supported for arrays or span types such as Span<T> and ReadOnlySpan<T>. (Microsoft Learn)

So this is not valid:

int[] numbers = [with(length: 10), 1, 2, 3];
Enter fullscreen mode Exit fullscreen mode

And honestly, that restriction makes sense.

Arrays and spans are not constructed like HashSet<T> or List<T>. A comparer would not mean anything there.


What I Like About This Feature

The best C# features often feel boring after five minutes.

Not because they are weak.

Because they fit so naturally that you forget the old friction existed.

This is one of those features.

Before:

var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    "dotnet",
    "DOTNET",
    "DotNet"
};
Enter fullscreen mode Exit fullscreen mode

After:

HashSet<string> tags =
[
    with(StringComparer.OrdinalIgnoreCase),
    "dotnet",
    "DOTNET",
    "DotNet"
];
Enter fullscreen mode Exit fullscreen mode

The second version keeps the collection expression style without hiding the important part.

It says:

  • This is a set.
  • This is how the set compares values.
  • These are the values.

That is exactly the order I want to understand the code in.


Final Thought

Collection expressions made collection creation shorter.

with(...) makes it more complete.

Because real collections are not always just lists of values. Sometimes they need rules. They need capacity hints. They need equality semantics. They need ordering behavior.

And when those rules matter, they deserve to live inside the expression, not beside it.

So the next time you create a case-insensitive set, or a domain-specific set where equality depends on one property, this syntax feels less like decoration and more like honesty:

HashSet<User> users =
[
    with(new UserEmailComparer()),
    new("ALEX@EXAMPLE.COM", "Alex"),
    new("alex@example.com", "Alexander")
];
Enter fullscreen mode Exit fullscreen mode

A collection is not only what it contains.

It is also how it decides what “the same” means.


Top comments (0)