csharp-collections-modern
Skill2 starsChanged 42 days agoMITView on GitHub
Use when writing or reviewing C#/.NET code that builds, exposes, or iterates collections — collection expressions and target typing, params collections/spans, choosing between Dictionary/HashSet (the default), FrozenDictionary/FrozenSet (rarely, only for a long-lived build-once/read-many table), ImmutableArray/ImmutableDictionary, arrays, and List<T>, using concrete collection types instead of IList/ICollection/IDictionary/ISet interfaces, Span<T>/stackalloc for local work, CollectionsMarshal, and when LINQ is fine vs when it isn't.
In this repository56 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- benchmarkingSkill
- commit-messagesSkill
- csharp-15-syntaxSkill
- csharp-aot-trimmingSkill
- csharp-api-designSkill
- csharp-assembly-inspectionSkill
- csharp-asyncSkill
- csharp-concurrency-primitivesSkill
- csharp-data-layoutSkill
- csharp-debuggingSkill
- csharp-discardsSkill
- csharp-disposable-patternsSkill
- csharp-docsSkill
- csharp-error-handlingSkill
- csharp-extension-membersSkill
- csharp-generics-modernSkill
- csharp-guard-clausesSkill
- csharp-json-source-generationSkill
- csharp-language-versionsSkill
- csharp-logging-diagnosticsSkill
- csharp-lspSkill
- csharp-modern-typesSkill
- csharp-nullabilitySkill
- csharp-nullable-migrationSkill
- csharp-nunitSkill
- csharp-pattern-matchingSkill
- csharp-performanceSkill
- csharp-polyfillsSkill
- csharp-source-generatorsSkill
- csharp-static-lambdasSkill
- csharp-string-handlingSkill
- csharp-tunitSkill
- csharp-verificationSkill
- dispatching-subagentsSkill
- dotnet-11-librariesSkill
- dotnet-analyzersSkill
- dotnet-central-package-managementSkill
- dotnet-file-based-appsSkill
- dotnet-msbuild-usingsSkill
- dotnet-multi-targetingSkill
- dotnet-project-hygieneSkill
- dotnet-runSkill
- dotnet-runtime-asyncSkill
- dotnet-slnxSkill
- dotnet-test-platformsSkill
- executing-plansSkill
- performance-bounded-deduplicationSkill
- roslyn-analyzer-performanceSkill
- roslyn-discoverySkill
- roslyn-duplicate-detectionSkill
- roslyn-guard-rewritersSkill
- roslyn-rewritersSkill
- subagent-driven-developmentSkill
- tddSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 29
Don't cast a collection-expression result to List<T> expecting it to succeed — the concrete type is an implementation detail.
- Very specific: line 76
Don't change an existing public params T[] to a span overload casually — it's a binary-breaking, source-compatible-only change.
- Distinctive: line 142
Use spans for slicing and short-lived buffers that never escape the method:
- Very specific: line 198
If a method returns IEnumerable<T> and the caller might enumerate more than once, materialize (ToList/ToArray) at the boundary rather than …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Modern collections (C# 13/14, .NET 10)
Collection choice is an API and performance decision, not a style preference. Pick the narrowest type that fits how the data is built, read, and exposed.
Collection expressions
[...] is target-typed — the compiler picks the concrete type from context, not from the syntax:
int[] arr = [1, 2, 3]; // T[]
List<int> list = [1, 2, 3]; // List<T>
Span<int> span = [1, 2, 3]; // stack-allocated for a ref struct target
int[] a = [1, 2];
int[] combined = [.. a, 5, 6]; // spread — concatenates/copiesFor interface targets, the compiler synthesizes a read-only wrapper over an array, not a List<T>:
IEnumerable<int> ie = [1, 2, 3]; // compiler-generated read-only array wrapper
IReadOnlyList<int> irl = [1, 2, 3]; // same — not a List<T>, don't downcast to oneDon't cast a collection-expression result to List<T> expecting it to succeed — the concrete type is an implementation detail. If you need List<T> specifically, target-type to it explicitly.
The spread element .. works on any type enumerable and lets the compiler pick a single-allocation, correctly-sized result — prefer it over Concat().ToArray() chains.
.NET 11 / C# 15 — collection expression arguments
Check TargetFramework/LangVersion first (see csharp-language-versions) — needs C# 15. It is a compiler feature with no runtime dependency, so it also compiles on a net10.0 target when LangVersion is latest on SDK 11 (verified); at the default C# 14 it fails with CS9327. A collection expression can now take a leading with(...) argument list to configure the collection being built — capacity, comparer, or both — instead of falling back to imperative construction:
List<string> names = [with(capacity: 100), .. values]; // pre-sized List<T>
HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "a", "A"]; // custom comparer, still target-typed
Dictionary<byte[], int> map = [with(new ByteArrayComparer())]; // comparer only, no elementswith(...) must be the first element and only applies when the target type has a matching constructor/factory shape (e.g. List<T>(int capacity), HashSet<T>(IEqualityComparer<T>)) — it's resolved the same way collection-expression construction already is, just with arguments threaded through instead of a bare new(). Prefer this over new List<T>(capacity) { ... }/new HashSet<T>(comparer) { ... } boilerplate once the project targets net11.0+.
.NET 11 library additions worth knowing
EqualityComparer<T>.Create<TKey>(Func<T,TKey> keySelector, IEqualityComparer<TKey>? keyComparer = null)— build a comparer from a key projection without hand-writingIEqualityComparer<T>:
(There's also avar byLength = EqualityComparer<string>.Create(s => s.Length); var set = new HashSet<string>(byLength);Create(Func<T?,T?,bool> equals, Func<T,int>? getHashCode = null)overload for a fully custom comparison.)- LINQ
FullJoin— outer join that keeps unmatched rows from both sides (filling the missing side withdefault/a supplied value), whereJoinonly keeps matches andGroupJoingroups matches per outer element. - Tuple-returning
Join/GroupJoinoverloads — skip the result-selector lambda when you just want the pairing:left.Join(right, l => l.Key, r => r.Key)returns(TLeft, TRight)tuples directly;left.GroupJoin(right, l => l.Key, r => r.Key)returnsIEnumerable<IGrouping<TKey, TRight>>directly, no selector needed. BitArrayspan constructors —new BitArray(ReadOnlySpan<bool> values)alongside the existingbool[]/int[]/byte[]constructors, so astackalloc/slice can seed aBitArraywithout an intermediate array. TheReadOnlySpan<byte>form is LSB first: byte0x01sets bit 0 and0x80sets bit 7. Formats that count bits from the most significant bit (PDF and TIFF bitmaps) need shifts instead. Seedotnet-11-libraries.
params collections and spans (C# 13)
params is no longer array-only. Prefer params ReadOnlySpan<T> for hot-path APIs — the compiler stack-allocates the backing storage for small, fixed call sites instead of heap-allocating an array:
static int Sum(params ReadOnlySpan<int> values)
{
var total = 0;
foreach (var v in values) total += v;
return total;
}
Sum(1, 2, 3); // no array allocation
Sum([4, 5, 6]); // collection expression also bindsRules:
params ReadOnlySpan<T>/params Span<T>cannot be used inasyncmethods or iterators (spans can't cross anawaitor be captured in state machines) — keep thoseparams T[].- A type only needs a
GetEnumerator(), indexer, andCount/Length(a "collection-like" shape) to supportparamsnow — you can author your ownparams-friendly collection types. - Don't change an existing public
params T[]to a span overload casually — it's a binary-breaking, source-compatible-only change; add an overload if both call shapes matter.
Choosing a collection type
| Need | Use |
| Default lookup/set — mutable or not, most processes | Dictionary<K,V> / HashSet<T> |
| Tiny fixed set of cases known at compile time | array, or a switch expression — no collection at all |
| Local, mutable, size known or grows during a method | List<T> / T[] |
| Fixed-size, no growth, tight loops, best cache locality | T[] |
| Small, fixed-size, value-semantics collection you compare/hash | ImmutableArray<T> |
| Need structural immutability with cheap incremental "with" edits | ImmutableDictionary<K,V> / ImmutableList<T> |
| Long-running process, one lookup table built once at startup, read millions of times after — and you've measured construction is amortized away | FrozenDictionary<K,V> / FrozenSet<T> |
FrozenDictionary/FrozenSetare rarely the right choice — treat them as an exception, not a default. Construction analyses the actual key set to build a specialized layout (perfect-hash-style branching tuned for that data), which is meaningfully more expensive to build than aDictionary/HashSet. That cost is only worth paying when the collection is built exactly once in a long-running process and then queried a very large number of times afterward — e.g. a server's static routing table or a compiler's fixed keyword set, built at startup and read for the life of the process.- Do not reach for
Frozen*for: short-lived processes, CLI tools, tests, analyzers/generators, request/response-scoped data, anything rebuilt per request or per compilation, or "it's read-only so it should be Frozen" reasoning. In all of these the construction cost is paid on (or near) every use, which makes it slower thanDictionary/HashSet, not faster. - Default to
Dictionary<K,V>/HashSet<T>(or a plain array/switchfor a handful of fixed cases) for lookup tables. Only move toFrozen*after profiling shows construction is amortized over enough reads to matter — measure with BenchmarkDotNet including construction cost, not just steady-state lookup time, before switching. ImmutableArray<T>/ImmutableDictionary<K,V>give you real structural immutability and safe sharing across threads without copy-on-write bookkeeping in your own code —ImmutableDictionaryedits areO(log n)persistent-tree operations, not full copies, but that means it is slower to read than a plainDictionary. Don't reach forImmutableDictionaryas a "safe default" for something only ever read — that's still justDictionaryterritory unless you need the structural-sharing/edit semantics.- Plain
Dictionary<K,V>/List<T>/arrays remain correct and fastest for anything mutable or short-lived. Don't addFrozen/Immutableceremony to a collection nobody shares or freezes. ImmutableArray<T>is a thin struct wrapper overT[]— cheap to pass around, but adefaultinstance is not the same asEmpty(it's null-backed and throws on iteration). Always initialize it.
// ✅ default choice — cheap to build, fast enough for almost everything
static readonly Dictionary<string, Command> Commands =
new() { ["run"] = Run, ["stop"] = Stop };
// ✅ Frozen only once measurement justifies it: built once at process startup,
// read millions of times over a long-running server's lifetime
static readonly FrozenDictionary<string, Route> Routes =
LoadRoutesFromConfig().ToFrozenDictionary();
// ❌ rebuilding per call pays the (larger) construction cost on every use —
// this is slower than a plain Dictionary, not faster
FrozenDictionary<string, Command> Lookup() => _raw.ToFrozenDictionary(); // called per requestUse concrete collection types, not collection interfaces
Declare collections with their concrete types: List<T>, Dictionary<TKey, TValue>, HashSet<T>, T[], ImmutableArray<T>. Don't use the mutable collection interfaces (IList<T>, ICollection<T>, IDictionary<TKey, TValue>, ISet<T>) for fields, locals, parameters or return types. This matches the .NET analyzer guidance (CA1859, "use concrete types when possible for improved performance"):
- Speed: calls through a concrete type are direct and can be inlined; through an interface they're virtual dispatch.
foreachover aList<T>uses its struct enumerator; overIList<T>the enumerator is boxed on older runtimes. - Clarity: the concrete type tells the reader exactly what they have: its complexity, ordering, whether it's a copy.
- Honesty: the mutable interfaces promise operations that often throw (
IList<T>.Addon an array, a read-only wrapper), so the type guarantees nothing.
// ✅ concrete types throughout
private readonly Dictionary<string, Order> _byId = [];
public List<Order> FindOpen(HashSet<string> ids) { ... }
// ❌ interface types that hide what the collection is and cost dispatch
private readonly IDictionary<string, Order> _byId = new Dictionary<string, Order>();
public IList<Order> FindOpen(ICollection<string> ids) { ... }Narrow exceptions:
IEnumerable<T>for a genuinely lazy or streaming sequence (an iterator, a query you don't want to materialise). Anything already materialised goes out as its concrete type.IReadOnlyList<T>/IReadOnlyDictionary<TKey, TValue>only when a public API deliberately exposes a read-only view of internal state without copying it. The view is a contract, not protection: a caller can cast back toList<T>. ReturnImmutableArray<T>or a copy when the guarantee matters. Reach forFrozenDictionary<TKey, TValue>only for the long-lived, build-once table discussed above, not by default.
private readonly List<Order> _orders = [];
public IReadOnlyList<Order> Orders => _orders; // read-only view of internal state, no copySpan<T> / ReadOnlySpan<T> / stackalloc for local work
Use spans for slicing and short-lived buffers that never escape the method:
ReadOnlySpan<char> csv = "a,bb,ccc";
var comma = csv.IndexOf(',');
ReadOnlySpan<char> first = csv[..comma]; // slice, no allocation
Span<int> scratch = stackalloc int[16]; // small, fixed, stack-only- Spans are
ref struct— cannot be a field of a non-ref structclass, cannot be boxed, cannot crossawait/yield. That's a feature: it keeps them from escaping. stackallocis for small, bounded sizes known not to blow the stack (tens to low hundreds of elements) — never with an unbounded/user-controlled length.- Slice instead of
Substring/ToArray/Skip().Take()when you're only reading a subrange.
CollectionsMarshal — controlled escape hatches
For hot paths where the collection API forces an extra lookup or copy:
// mutate a List<T>'s backing storage in place, no copy
Span<int> span = CollectionsMarshal.AsSpan(list);
span[0] = 42;
// one lookup instead of ContainsKey + indexer/Add
ref int slot = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out var existed);
if (!existed) slot = ComputeInitial();
slot++;Caveats: AsSpan is invalidated by any operation that resizes the list (Add past capacity, RemoveAt, etc.) — don't mutate the list's shape while holding the span. GetValueRefOrAddDefault returns a ref into the dictionary's internal storage — it's invalidated by any subsequent resize of the dictionary, so use it immediately, not stashed across other mutations.
LINQ in hot paths
LINQ is fine on cold/startup paths and for readability — the iterator and delegate overhead is real but usually irrelevant off the hot path. On measured hot loops (per-frame, per-request, per-notification), prefer a manual for/foreach — see csharp-performance for the allocation reasoning.
Newer LINQ methods worth knowing (avoid hand-rolled equivalents):
words.CountBy(w => w[0]); // group + count in one pass
words.AggregateBy(w => w[0], 0, (acc, w) => acc + w.Length); // group + fold, no intermediate groupings
words.Index(); // (int Index, T Item) pairs, replaces .Select((w, i) => (i, w))Avoid multiple enumeration. An IEnumerable<T> from a LINQ query or a yield return iterator re-runs its whole pipeline (including side effects and I/O) on every enumeration:
// ❌ query runs twice
IEnumerable<int> Query() => data.Where(x => x > 0);
var q = Query();
if (q.Any()) Process(q.ToList()); // enumerated once for Any, again for ToList
// ✅ materialize once, reuse
var results = Query().ToList();
if (results.Count > 0) Process(results);If a method returns IEnumerable<T> and the caller might enumerate more than once, materialize (ToList/ToArray) at the boundary rather than letting each call site guess.
Checklist
- Collection-expression target type is the one you actually want (
IReadOnlyList<T>/IEnumerable<T>are notList<T>) params ReadOnlySpan<T>on hot-path variadic APIs, notasync/iterator methods- On C# 15 (
net11.0, ornet10.0withLangVersion=lateston SDK 11):[with(capacity: n), ...]/[with(comparer), ...]used instead ofnew List<T>(n) { ... }boilerplate where the TFM allows it Dictionary/HashSetby default;Frozen*only in a long-running process, with a benchmark that includes construction costImmutable*only where structural sharing/immutability is actually needed- Collections declared as concrete types (
List<T>,Dictionary<TKey, TValue>,HashSet<T>,T[]), neverIList<T>/ICollection<T>/IDictionary<TKey, TValue>/ISet<T> IEnumerable<T>only for lazy sequences;IReadOnlyList<T>/IReadOnlyDictionary<TKey, TValue>only for a deliberate read-only view of internal state- Spans/
stackallocstay local, bounded, and never crossawait CollectionsMarshalrefs/spans used immediately, not held across a resize- LINQ avoided on measured hot loops; no query re-enumerated by accident
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

