DEV Community

JKC
JKC

Posted on

Stop Dropping Back to new() for a Comparer. C# 15 Has with()

TL;DR: In C# 15, a collection expression can start with with(...), and those arguments go to the constructor or factory: HashSet<string> tags = [with(StringComparer.OrdinalIgnoreCase), "a", "A"];. It ships with .NET 11, which is at Release Candidate 1 right now: go-live licensed, but not final yet.

The bug that looks like a style choice

Collection expressions made C# collections pleasant. [1, 2, 3], spreads with .., one syntax for lists, sets, arrays, and spans.

Until you need a comparer.

HashSet<string> tags = ["Hello", "HELLO", "hello"];
Console.WriteLine(tags.Count); // 3
Enter fullscreen mode Exit fullscreen mode

That set holds three "different" strings. If you wanted case-insensitive tags, the brackets had no place to put the comparer. So you went back to the old shape:

var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Hello", "HELLO", "hello" };
Enter fullscreen mode Exit fullscreen mode

It works. It also means half your codebase uses brackets and the other half uses new, and the half with new is the half that actually matters. Worse, the version without the comparer compiles just fine, so forgetting it is a silent bug, not a red squiggle.

Meet the with() element

C# 15 adds one new kind of element. Put with(...) first in the brackets, and its arguments go to the collection's constructor:

HashSet<string> tags = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"];

Console.WriteLine(tags.Count);            // 1
Console.WriteLine(tags.First());          // Hello
Console.WriteLine(tags.Contains("HeLLo")); // True
Enter fullscreen mode Exit fullscreen mode

Capacity works the same way, named arguments included:

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

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

The same list built as [.. values] reported a capacity of 3 in my run. Now you can leave room for the items you are about to add, without leaving the brackets.

The compiler picks the constructor with normal overload resolution. For types with a [CollectionBuilder] factory, such as ImmutableHashSet<T>, the arguments go to the factory method instead, in front of the elements:

ImmutableHashSet<string> ids = [with(StringComparer.OrdinalIgnoreCase), "a", "A", "b"];
Console.WriteLine(ids.Count); // 2
Enter fullscreen mode Exit fullscreen mode

A realistic one: allowed file types

Upload checks love case bugs. Your defaults say .png, your config says .PNG, and the user uploads cat.JPG.

string[] defaults = [".jpg", ".png"];
string[] fromConfig = [".PNG", ".Webp"];

HashSet<string> allowed =
[
    with(StringComparer.OrdinalIgnoreCase),
    .. defaults,
    .. fromConfig,
    ".gif",
];

Console.WriteLine(allowed.Count);

foreach (var file in new[] { "cat.JPG", "doc.pdf", "meme.WEBP" })
    Console.WriteLine($"{file}: {allowed.Contains(Path.GetExtension(file))}");
Enter fullscreen mode Exit fullscreen mode

Output from the run:

4
cat.JPG: True
doc.pdf: False
meme.WEBP: True
Enter fullscreen mode Exit fullscreen mode

Two spreads, one literal, and .png and .PNG collapse into one entry. The comparer sits on the first line, where a reviewer will actually see it.

It works for ordering too. A leaderboard that keeps the highest score first:

var highestFirst = Comparer<int>.Create((a, b) => b.CompareTo(a));

SortedSet<int> scores = [with(highestFirst), 120, 450, 300, 450];
Console.WriteLine(string.Join(", ", scores)); // 450, 300, 120
Enter fullscreen mode Exit fullscreen mode

The duplicate 450 is gone, because it is still a set. The set just reads top-down now.

Can I use it?

  • Collection expression arguments are part of C# 15. On the .NET 11 RC1 SDK, #error version reports Language version: 15.0, and every snippet here compiled with no LangVersion set.
  • .NET 11 RC1 shipped on September 8, 2026 with a go-live support license, so it is allowed in production. It is still a release candidate, not the final release.
  • On the .NET 10 SDK (C# 14), the same code fails with CS8652: The feature 'collection expression arguments' is currently in Preview and *unsupported*.

I ran every snippet here as a file-based app with dotnet run file.cs on SDK 11.0.100-rc.1.

Gotchas

1. It has to come first. List<int> l = [1, with(capacity: 4)]; gives CS9354: 'with(...)' element must be the first element. Arguments describe the collection, so they go before anything goes in it.

2. Arrays and spans say no. int[] a = [with(), 1, 2]; gives CS9355: 'with(...)' elements are not supported for type 'int[]'. There is no constructor to pass anything to, so even an empty with() is an error.

3. Read-only interfaces only take an empty with(). IEnumerable<int> e = [with(capacity: 4), 1]; gives CS9357: 'with(...)' element for a read-only interface must be empty if present. IList<T> and ICollection<T> are different: they accept a capacity, and in my run IList<int> n = [with(capacity: 100), 1, 2, 3]; came back as a List<int> with a Capacity of 100.

4. It is a constructor call, with constructor surprises. List<int> l = [with([1, 2]), 3]; printed [1, 2, 3], because List<T> has a constructor that takes an IEnumerable<T>. And [with(default)] fails with CS0121, since default fits both List(int) and List(IEnumerable<int>). Name your arguments (capacity:) and the guessing stops.

5. Dictionaries get the comparer, not the elements. Dictionary<string, int> ages = [with(StringComparer.OrdinalIgnoreCase)]; works, and ages["ALICE"] then finds the "Alice" entry. Adding new KeyValuePair<string, int>("a", 1) as an element fails with CS9215, because Dictionary has no public Add that takes a pair. Key-value elements in brackets are a separate feature, not part of C# 15.

6. Evaluation order is left to right. The with(...) arguments run before any element. I logged each one in [with(capacity: Log("arg", 4)), Log("e1", 1), Log("e2", 2)], and the output was arg, then e1, then e2. Code reads in the same order it runs, which is more than some config files can say.

Wrap-up

A forgotten comparer is a bug that compiles, passes review, and waits for the first user named ALICE. with(...) puts it on line one of the collection, in the same brackets as everything else. Go grep your codebase for new HashSet<string>(StringComparer and see how many of them can come home to the brackets.

Sources

This article was written with the help of AI and checked against the sources above.

Top comments (1)

Collapse
 
mehdimohseni82 profile image
Mehdi Mohseni •

Good catch on the read-only interfaces only taking an empty with(). The mutable ones go the other way, and that's handy for return types: per the feature spec, IList and ICollection targets map to the List() and List(int) constructors, so IList names = [with(capacity: 20), .. values]; is allowed and you still get the presized List behind the interface.