📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are collection expressions in C# 12? Explain the [a, b, ..c] syntax, which target types they support (arrays, spans, lists, interfaces, CollectionBuilder types), why they have no natural type, how the compiler chooses an efficient implementation, the spread operator, and the performance and API design implications.

Collection expressions: syntax, target typing, CollectionBuilder, spread and performance behaviour.

Mid-Level Theory Intermediate collection-expressions collections csharp-12 performance spans

🎯 Key points

  • Collection expressions (C# 12) use [a, b, ..c] to create many collection types
  • They are target-typed and have no natural type, so var does not compile
  • Supported targets: arrays, spans, collection-initialiser types, CollectionBuilder types and common interfaces
  • The spread element enumerates another collection; known counts allow pre-sizing
  • The compiler optimises: Array.Empty for [], assembly data for constant spans, stack storage for non-escaping spans
  • Interface targets get a compiler-chosen implementation that you should not cast
  • Span-accepting APIs plus collection expressions can avoid heap allocations

⏱ 30-second answer

A collection expression (C# 12) is [e1, e2, ..other]: a single syntax that creates arrays, List<T>, Span<T>, ReadOnlySpan<T>, immutable collections and more. It is target-typed: the type on the left (or the parameter type) decides what is built, so var x = [1, 2]; does not compile. Supported targets are arrays, spans, types with a collection initialiser (IEnumerable plus Add), types marked [CollectionBuilder] (such as ImmutableArray<T>), and the interfaces IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection<T> and IList<T>. .. spreads another collection's elements. The compiler picks efficient code: [] to an array uses Array.Empty<T>(), constant data for ReadOnlySpan<byte> lives in the assembly, a non-escaping Span<T> can avoid the heap, and known sizes pre-size lists.

🗣 Full answer (senior)

How I would answer this in an interview

Collection expressions (C# 12) are a unified syntax for creating collections: [a, b, c], empty [], and spreading other collections with ... The compiler looks at the target type and emits the most efficient construction for it — arrays, spans (often on the stack), lists, immutable collections, or interfaces. I'd explain the syntax, supported targets, why there's no natural type, how the compiler chooses an implementation, spreads, and the performance and API design implications.

1. Syntax

int[] ids = [1, 2, 3];
List<string> states = ["NSW", "VIC", "QLD"];
Span<byte> header = [0x50, 0x4B, 0x03, 0x04];
ImmutableArray<decimal> rates = [0.1m, 0.05m];
IReadOnlyList<Order> none = [];

int[] merged = [..ids, 4, ..moreIds];          // spread operator
List<Order> all = [..pending, ..paid.Where(o => o.Total > 0)];

2. Supported target types

  • Arrays (T[]).
  • Span<T> / ReadOnlySpan<T>.
  • Types with a collection initialiser pattern (implement IEnumerable + have Add) such as List<T>, HashSet<T>, Dictionary isn't supported directly in C# 12/13 (key-value collection expressions are a future feature).
  • Types marked with [CollectionBuilder(typeof(Builder), "Create")] — the compiler calls a static Create(ReadOnlySpan<T>) method. ImmutableArray<T>, ImmutableList<T>, FrozenSet<T>-style types use this.
  • Interfaces: IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T> (compiler picks a read-only implementation, often an array or a synthesised type), ICollection<T>, IList<T> (compiler uses List<T>).

3. Why there's no natural type

var x = [1, 2, 3];   // error CS9176: no target type

The whole point is that the same expression should compile to the best representation for its target (stack span, array, list, immutable). Choosing a default (e.g. List<T>) would bake in an allocation and make code less efficient silently. The language team left "natural type" for possible future design. Use an explicit type or a target.

4. How the compiler chooses an efficient implementation

Examples of what the compiler does:

  • int[] a = [1, 2, 3]; → allocate an array of length 3 and fill it (or use RuntimeHelpers.InitializeArray for constants).
  • ReadOnlySpan<byte> s = [1, 2, 3]; with constants → no allocation: points to data embedded in the assembly.
  • Span<int> s = [x, y, z]; → may use an inline array on the stack.
  • List<int> l = [x, y, ..other]; → creates the list with the known capacity (if the spread source's count is known) and uses CollectionsMarshal.SetCount/AsSpan to fill it efficiently.
  • ImmutableArray<int> i = [1, 2]; → calls ImmutableArray.Create(ReadOnlySpan<int>) via [CollectionBuilder], avoiding builder overhead.
  • IEnumerable<T> e = []; → uses a cached empty array (Array.Empty<T>()).
  • IReadOnlyList<T> r = [a, b]; → a compiler-synthesised read-only type or an array.

Spreads: the compiler uses Count/Length when available to pre-size, and CopyTo/span copies for arrays and lists rather than enumerating.

5. Performance and API design implications

  • Collection expressions are usually as fast or faster than hand-written code, especially into spans and pre-sized lists.
  • They make params ReadOnlySpan<T> (C# 13) and span-based APIs feel natural: Process([1, 2, 3]) with no array allocation.
  • APIs taking IEnumerable<T>/IReadOnlyList<T> accept [...] from callers easily — a reason to accept interfaces.
  • If you write custom collection types, add [CollectionBuilder] so they work with the syntax efficiently.
  • Be careful returning collection expressions to IEnumerable<T>: callers may cast back to the concrete type the compiler picked — don't rely on it, and don't mutate.
  • Spreading large sequences copies elements — [..huge] is a full copy.
  • Watch overload resolution: C# 12 prefers ReadOnlySpan<T> overloads for collection expressions in some cases; C# 13/14 refined "better conversion" rules for spans.

6. A production example

A validation library built small arrays of allowed values on every call (new[] { "NSW", "VIC", ... }.Contains(state)), allocating per request. Converting to static ReadOnlySpan<string>... (strings can't be in a constant data span), so we used static readonly FrozenSet<string> AllowedStates = ["NSW", "VIC", "QLD", "WA", "SA", "TAS", "ACT", "NT"]; via the collection expression + [CollectionBuilder] support for frozen sets (or ToFrozenSet() on older targets), and byte signatures became ReadOnlySpan<byte> Png = [0x89, 0x50, 0x4E, 0x47]; with zero allocation. Allocations in the validator dropped to zero and the code became shorter.

🔍 What the interviewer is checking

The interviewer checks that you understand target typing and the performance model, not just the brackets.

Green flags

  • Explains target typing and why var does not work.
  • Lists supported targets including CollectionBuilder types and interfaces.
  • Knows the spread operator and when the compiler can pre-size.
  • Talks about spans, empty arrays and API design.

Red flags

  • Thinks a collection expression always creates a List<T>.
  • Casts interface results to concrete types.
  • Unaware of CollectionBuilder.

📚 Learn in depth

1. CollectionBuilder

[CollectionBuilder(typeof(MoneyBag), nameof(MoneyBag.Create))]
public sealed class MoneyBag : IEnumerable<Money>
{
    public static MoneyBag Create(ReadOnlySpan<Money> items) => new(items.ToArray());
    ...
}

MoneyBag bag = [Money.Aud(10), Money.Aud(5)];

The compiler builds a span (often stack-allocated) of the elements and calls Create. The builder method must be static, accessible, take a ReadOnlySpan<T> and return the collection type.

2. Spans from constants

For ReadOnlySpan<T> of primitive constants, the compiler emits the data into the PE file and creates a span over it (RuntimeHelpers.CreateSpan) — no allocation, no copying, just like u8 literals. For non-constant elements, it may use [InlineArray] types on the stack.

3. Interfaces and synthesised types

For IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, the compiler may synthesise an internal read-only wrapper type (<>z__ReadOnlyArray<T>) to prevent mutation via casting to IList<T>. For ICollection<T>/IList<T>, it uses List<T> (mutable).

4. Overload resolution

When methods have overloads taking T[], List<T>, ReadOnlySpan<T>, IEnumerable<T>, collection expression arguments prefer span overloads, then arrays, etc., based on "better conversion from expression" rules (C# 12, refined in C# 13 with params spans and C# 14 first-class spans).

5. Empty collections

[] is the cheapest way to get an empty collection: Array.Empty<T>() for arrays/interfaces, default/empty span for spans, new List<T>() for lists, ImmutableArray<T>.Empty via builder.

6. Limitations

  • No dictionary syntax yet ([k: v] has been proposed).
  • Needs a target type.
  • Element types must convert to the collection's element type.
  • Spreads require IEnumerable<T>-compatible sources (or spans).

Try it yourself

  1. Decompile assignments of [1,2,3] to int[], ReadOnlySpan<int>, List<int>, IEnumerable<int>.
  2. Add [CollectionBuilder] to a custom collection.
  3. Benchmark creating a List<int> with spreads vs AddRange.
  4. Try var x = [1]; and read the error.

💻 Code

using System.Collections;
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;

[CollectionBuilder(typeof(TagSetBuilder), nameof(TagSetBuilder.Create))]
public sealed class TagSet : IEnumerable<string>
{
    private readonly FrozenSet<string> _tags;
    internal TagSet(FrozenSet<string> tags) => _tags = tags;
    public bool Contains(string tag) => _tags.Contains(tag);
    public IEnumerator<string> GetEnumerator() => _tags.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

public static class TagSetBuilder
{
    public static TagSet Create(ReadOnlySpan<string> items) =>
        new(items.ToArray().ToFrozenSet(StringComparer.OrdinalIgnoreCase));
}

public static class Program
{
    static int Sum(ReadOnlySpan<int> values)          // span parameter: callers can avoid heap arrays
    {
        int total = 0;
        foreach (int v in values) total += v;
        return total;
    }

    public static void Main()
    {
        int[] weekdays = [1, 2, 3, 4, 5];
        List<int> weekend = [6, 7];
        int[] all = [..weekdays, ..weekend];          // spread: sizes known, so pre-sized
        ImmutableArray<string> states = ["NSW", "VIC", "QLD"];  // CollectionBuilder
        IReadOnlyList<string> codes = [..states, "WA"];         // compiler picks a read-only type
        Span<int> scratch = [0, 0, 0, 0];             // can live on the stack
        int[] none = [];                              // Array.Empty<int>()
        TagSet tags = ["vip", "Express"];             // our own builder

        scratch[0] = Sum([10, 20, 30]);               // no heap array for the argument
        Console.WriteLine($"{all.Length} {codes.Count} {scratch[0]} {none.Length} {tags.Contains("EXPRESS")}");

        // var oops = [1, 2];                         // CS9176: no natural type
    }
}

⚠ Common mistakes

1. Expecting a natural type

What happens: var items = [1, 2];. Why it is wrong: no target type — compile error. Do instead: declare the type.

2. Relying on the compiler's concrete type

What happens: casting an IEnumerable<T> from [...] to List<T>. Why it is wrong: the compiler may choose arrays or synthesised types. Do instead: treat interface-typed results as read-only.

3. Spreading large sequences unnecessarily

What happens: [..hugeList] to "convert" types. Why it is wrong: full copy. Do instead: avoid copies or use views/spans.

4. Allocating per call for constant data

What happens: new[] { ... } inside methods. Why it is wrong: allocations per call. Do instead: ReadOnlySpan<T> constants or static readonly collections.

5. Custom collections without CollectionBuilder

What happens: custom types used with collection expressions via Add. Why it is wrong: less efficient construction. Do instead: add [CollectionBuilder].

6. Mutating results typed as interfaces

What happens: casting IReadOnlyList<T> results to IList<T> and modifying. Why it is wrong: may throw or break assumptions. Do instead: create explicit List<T> when mutation is needed.

7. Overload surprises

What happens: a different overload (span vs array) is chosen after converting to collection expressions. Why it is wrong: behaviour differences in overloads. Do instead: keep overload semantics consistent.

8. Expecting dictionary syntax

What happens: Dictionary<string,int> d = ["a": 1];. Why it is wrong: not supported yet. Do instead: dictionary initialisers new() { ["a"] = 1 }.

🏛 Senior insight: trade-offs

Collection expressions are more than shorter syntax: they let the compiler pick the cheapest correct representation, and they make span-based APIs pleasant to call. That changes API design advice:

API parameter Caller writes Cost
ReadOnlySpan<T> M([a, b]) often no heap allocation
params ReadOnlySpan<T> (C# 13) M(a, b) or M([a, b]) often no heap allocation
IEnumerable<T> M([a, b]) allocation for the backing storage
T[] M([a, b]) array allocation

The other senior point is consistency: one syntax for every collection makes code reviews easier and lets IDE0300-series analysers modernise old initialisers automatically. The risk is relying on unspecified concrete types behind interfaces, so I would flag casts in review.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate picks List, array or ImmutableArray targets. Why: Choosing the target collection type still matters.
    How to answer

    Choose by access pattern, then mutability, then concurrency. Index access or iteration: List<T>/array. Lookup by key: Dictionary. Uniqueness or membership: HashSet. Order of processing: Queue/Stack/PriorityQueue. Sorted keys: SortedList (read-mostly) or SortedDictionary (write-heavy). Shared across threads: ConcurrentDictionary/Channel<T>, or an immutable/frozen snapshot if writes are rare. Built once and read forever: FrozenDictionary/FrozenSet/ImmutableArray. For APIs, accept the least specific type you need (IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, or ReadOnlySpan<T>) and return a specific read-only type (IReadOnlyList<T>, IReadOnlyDictionary, or an immutable type), not a mutable List<T>.

  2. When: Candidate mentions ImmutableArray. Why: CollectionBuilder is how immutable and frozen collections support the syntax.
    How to answer

    Immutable collections (System.Collections.Immutable) never change after creation; "modifying" methods such as Add return a new collection and leave the old one untouched, so they are safe to share across threads without locks. ImmutableArray<T> is a struct around a plain array: reads are as fast as an array, but every Add copies the whole thing (O(n)). ImmutableList<T> and ImmutableDictionary<TKey,TValue> are balanced trees that share structure: an Add is O(log n) and reuses most of the old nodes, but reads are slower. Use builders (ToBuilder() ... ToImmutable()) for many changes. A read-only view like ReadOnlyCollection<T> or IReadOnlyList<T> only stops you changing it; the underlying list can still change. FrozenDictionary/FrozenSet (.NET 8+) are immutable too but optimised purely for fast reads with no "modified copy" operations.

  3. When: Candidate mentions bracket syntax in patterns. Why: List patterns are the matching counterpart of collection expressions.
    How to answer

    A list pattern (C# 11) matches a sequence by its elements: [] is empty, [var x] has exactly one element, [1, _, 3] has three with given first and last, [var first, ..] has at least one, and [.., var last] captures the last. Each element position takes any pattern (constant, relational, type, var, _). .. is a slice pattern matching zero or more elements, and at most one is allowed; .. var rest captures the slice. A type supports list patterns if it is countable (Length or Count) and indexable (an int or Index indexer); capturing a slice also needs a Range indexer or a Slice method. Arrays, List<T>, strings, Span<T> and ReadOnlySpan<T> work; IEnumerable<T> does not. Slices of spans are free, while slices of arrays, lists and strings allocate copies.

🛠 Real-world problems: how you handle them

Per-call tag arrays in a logging helper
Situation
A telemetry helper called on every request of an API at [3,000] requests per second took `KeyValuePair<string, object?>[]` tags, and callers wrote `new[] { ... }` each time.
Signals
Allocation profiles showed the tag arrays among the top allocations, and Gen 0 collections were frequent under load.
Investigation
dotnet-trace with the GC allocation events identified the helper's array argument as the main allocation site.
Resolution
Changed the helper to accept `params ReadOnlySpan<KeyValuePair<string, object?>>` and updated callers to use collection expressions or plain argument lists.
Prevention
Added an allocation benchmark for the helper and a review guideline to prefer span parameters for small, short-lived inputs.

How to tell it (STAR)

**Situation:** our [API gateway] logged telemetry tags on every request and allocation profiles showed those tag arrays near the top. **Task:** I wanted to cut allocations without changing every call site by hand. **Action:** I changed the helper to accept a params ReadOnlySpan, which works with collection expressions, and let the IDE fix-ups update callers. **Result:** the tag allocations disappeared from the profile, Gen 0 collections dropped by [25%], and the call sites became shorter.

↩ Usually asked after

More in Modern C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me