← Open in the full interactive course (progress tracking, search & more)

One bracket syntax, and the compiler figures out whether you meant an array, a List, a Span, or something else entirely.

Quick: what's the syntax for creating a new array with three numbers in it? An empty List<string>? A Span<int> with the same three numbers? If you've written C# for a while, you know the honest answer is "three different syntaxes, and you'd better remember which is which" — new[] { 1, 2, 3 }, new List<string>(), stackalloc int[] { 1, 2, 3 }. Each collection type historically brought its own initializer dialect.

C# 12 introduces a single, unified syntax that works across nearly every collection type in .NET: square brackets. [1, 2, 3] means "a collection containing 1, 2, and 3" — and the compiler figures out from context whether that should become an array, a List<T>, a Span<T>, or something else, without you writing a different incantation for each one.

In this lesson, you'll learn collection expression syntax ([1, 2, 3]), the spread operator (..) for combining collections, how the same syntax adapts across arrays, List<T>, Span<T>, and other collection types, and why this replaces a decade of increasingly inconsistent initializer syntax.

What Is It?

The Simple Explanation

A collection expression is a bracket-enclosed list of values — [1, 2, 3] — that creates a collection. What kind of collection it becomes depends entirely on what you're assigning it to: assign it to an int[] and you get an array; assign it to a List<int> and you get a list. Same syntax, different result, chosen by context — exactly the way 0 can be an int, a double, or a byte depending on where it's used.

The Technical Definition

A collection expression (C# 12) is a target-typed expression of the form [e1, e2, ..., en] that the compiler converts into whatever collection type the expression is being assigned or passed to — an array, List<T>, Span<T>, ReadOnlySpan<T>, any type implementing the standard collection interfaces (like IEnumerable<T>), or any type exposing a compatible Create factory pattern recognized via the [CollectionBuilder] attribute. The spread element .. inlines the contents of another collection directly into a collection expression, at the position where it appears.

Why Does It Exist?

The Problem

Every collection type in .NET grew its own initializer syntax over the years, and none of them match:

int[] numbers = new int[] { 1, 2, 3 };
int[] shortForm = { 1, 2, 3 };                    // only legal at declaration
List<int> list = new List<int> { 1, 2, 3 };
List<int> listInferred = new() { 1, 2, 3 };        // target-typed new, C# 9
Span<int> span = stackalloc int[] { 1, 2, 3 };
int[] combined = numbers.Concat(new[] { 4, 5 }).ToArray(); // "add two arrays together"

Beyond the inconsistency, that last line highlights a real pain point: combining collections — "take everything from this array, then everything from that one" — required reaching for LINQ's Concat and ToArray, turning a conceptually simple operation into a multi-step chain.

The Need

Developers needed one consistent syntax for "here are the values in this collection" that worked the same way regardless of the target type, plus a direct, readable way to combine or inline other collections without a detour through LINQ.

The Solution

The same code, with collection expressions:

int[] numbers = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
int[] combined = [.. numbers, 4, 5]; // spread numbers' contents in, then append 4 and 5

One bracket syntax across every target, and the spread operator makes combining collections a direct, readable part of the literal itself.

Big Picture

ONE SYNTAX, MANY TARGETS
[1, 2, 3]
compiler looks at the target type
int[]  ·  List<int>  ·  Span<int>  ·  IEnumerable<int>  ·  HashSet<int>
Same literal, different generated code — chosen entirely by what you're assigning it to.

How It Works

COLLECTION EXPRESSIONS — STEP BY STEP
1. BASIC LITERALS — TARGET-TYPED
int[] numbers = [1, 2, 3];
List<string> names = ["Alice", "Bob"];
Dictionary<string, int> scores = new() { ["Alice"] = 90 }; // dictionaries use their own syntax still
2. THE EMPTY COLLECTION
List<int> empty = []; // equivalent to new List<int>()
int[] emptyArray = [];
3. THE SPREAD OPERATOR (..) — INLINING ANOTHER COLLECTION
int[] first = [1, 2, 3];
int[] second = [4, 5];

int[] combined = [.. first, .. second];     // [1, 2, 3, 4, 5]
int[] withExtra = [0, .. first, 99];        // [0, 1, 2, 3, 99]
4. WORKS ACROSS COLLECTION TYPES CONSISTENTLY
ReadOnlySpan<int> span = [1, 2, 3];  // no heap allocation for this one
HashSet<string> tags = ["c#", "dotnet"];
IEnumerable<int> sequence = [1, 2, 3];

Simple Example

int[] weekdays = [1, 2, 3, 4, 5];
int[] weekend = [6, 7];

int[] fullWeek = [.. weekdays, .. weekend];
Console.WriteLine(string.Join(", ", fullWeek)); // 1, 2, 3, 4, 5, 6, 7

List<string> baseTags = ["c#", "dotnet"];
List<string> extendedTags = [.. baseTags, "records", "patterns"];
Console.WriteLine(string.Join(", ", extendedTags)); // c#, dotnet, records, patterns

Code → Meaning → Result: [.. weekdays, .. weekend] reads almost like plain English — "everything from weekdays, then everything from weekend" — and produces a brand-new array holding all seven values, without a single call to Concat or ToArray.

Real-World Example

Building a product catalog page that combines a curated list of "featured" products with the regular catalog, while excluding anything currently out of stock:

public record Product(string Name, decimal Price, bool InStock);

List<Product> GetFeaturedProducts() =>
[
    new Product("Wireless Mouse", 24.99m, true),
    new Product("Mechanical Keyboard", 89.99m, true)
];

List<Product> GetCatalogProducts(List<Product> inventory)
{
    List<Product> featured = GetFeaturedProducts();
    List<Product> inStockInventory = inventory.Where(p => p.InStock).ToList();

    // Featured items always appear first, followed by the rest of the in-stock catalog
    return [.. featured, .. inStockInventory];
}

// ─── Usage: adding a promotional item on top of an existing list, without mutating it ───
List<Product> catalog = GetCatalogProducts(inventory);
List<Product> withPromo = [new Product("Flash Sale Item", 9.99m, true), .. catalog];

Each line reads as a direct description of what's being built — "featured, then in-stock inventory," or "a promo item, then the whole existing catalog" — with the spread operator doing the work that would otherwise need Concat, Prepend, or manual loop-and-add code.

Under the Hood

HOW THE COMPILER PICKS THE RIGHT CODE
THE COMPILER CHOOSES THE MOST EFFICIENT CONSTRUCTION STRATEGY FOR THE TARGET TYPE

The upshot: collection expressions aren't just shorter to write — the compiler often generates more efficient code than the equivalent hand-written initializer, because it can pick the best strategy for each specific target type rather than you having to know and choose it yourself.

Common Confusion

1. [] needs a target type — it can't stand entirely on its own

Just like new() (target-typed new, C# 9), a collection expression needs to know what type it's becoming. var x = [1, 2, 3]; doesn't compile — the compiler has no target to infer from, since var alone gives it nothing to pick from. You need an explicit target: a declared type (int[] x = [1, 2, 3];), a parameter type, or a return type.

2. The spread operator .. here is unrelated to the range operator .. used in indexing

C# also uses .. for range expressions like array[1..3] (a slice from index 1 up to, but not including, index 3). Inside a collection expression, .. means something different — "spread this entire collection's elements in here" — not a range. Context (inside [...] as a standalone prefix before a collection, versus inside indexing brackets) disambiguates the two uses.

3. Dictionaries don't (yet) use collection-expression bracket syntax

You still initialize a Dictionary<TKey, TValue> with its existing collection-initializer or index-initializer syntax (new() { ["key"] = value }). Collection expressions currently target sequence-shaped collections (things you enumerate one element at a time), not key/value stores.

Common Mistakes

Mistake 1 — Trying to use a collection expression without a clear target type

var numbers = [1, 2, 3]; //  CS9176: there is no target type for this collection expression

Give the compiler a type to target:

int[] numbers = [1, 2, 3]; //  fine

Mistake 2 — Forgetting that spreading into an array still creates a brand-new array

Assuming [.. original, extraItem] somehow appends in place, mutating original:

int[] original = [1, 2, 3];
int[] extended = [.. original, 4];

original[0] = 99;
Console.WriteLine(extended[0]); // still 1 — extended is a completely separate array

Understand that a collection expression, spread or not, always constructs a new collection — it copies elements in, it never reaches back into the source collection to modify it.

Mistake 3 — Spreading a very large collection repeatedly in a hot loop

Each spread still has to enumerate and copy the source collection's elements. Spreading a large collection inside a loop that runs many times can add up to real, avoidable allocation and copying cost — the same caution that applies to any repeated collection-building code, collection expressions included.

When Should I Use It?

Mental Model

[1, 2, 3] = "here are the values" — the target type decides what kind of collection they become.
.. (inside brackets) = "and pour in everything from this other collection, right here."

Remember: a collection expression always builds a new collection — spreading never mutates the source, and the syntax needs a known target type to compile.

Key Takeaway


Check Your Understanding

You've seen how one bracket syntax adapts across collection types, and how the spread operator inlines other collections. Let's check your understanding.

1. Why does var numbers = [1, 2, 3]; fail to compile?

Show answer

Correct: B

Why B is correct: Collection expressions are target-typed — the same way new() needs a known type to construct, [1, 2, 3] needs to know whether it should become an array, a list, a span, or something else. var provides no such information, so the compiler has nothing to convert the expression into.

Why A is incorrect: Collection expressions work with any element type, numbers included — the failure has nothing to do with the element type.

Why C is incorrect: The spread operator is entirely optional; plain literal elements are completely valid on their own.

Why D is incorrect: There's no minimum element count — even an empty [] is valid, as long as a target type is known.

Reinforcement: Always pair a collection expression with an explicit target type — a declared variable type, a parameter type, or a return type.

2. Given int[] a = [1, 2]; int[] b = [.. a, 3]; a[0] = 99;, what is b[0] after this code runs?

Show answer

Correct: B

Why B is correct: A collection expression always constructs a genuinely new collection. Spreading a into b's literal copies the values at that point in time — b is a completely independent array afterward, so mutating a later has no effect on b.

Why A is incorrect: Collection expressions don't create aliases or shared references — each one builds its own new collection.

Why C is incorrect: Nothing about this code involves a null reference — both arrays are fully constructed and valid.

Why D is incorrect: Spreading preserves the original order of elements; it doesn't shuffle them.

Reinforcement: Spreading copies values in at the moment the collection expression runs — it never creates a live link back to the source collection.

3. Which of these best explains why collection expressions can be more efficient than hand-written initializer syntax, not just shorter?

Show answer

Correct: B

Why B is correct: Because the compiler knows the exact target type at compile time, it can generate the most appropriate construction code for that specific type — direct allocation for arrays, capacity-aware construction for lists, inline (non-heap) storage for spans — often better than a generic hand-rolled approach would achieve.

Why A is incorrect: The efficiency gain is about choosing the right strategy per target type, not a blanket "always less memory" guarantee.

Why C is incorrect: Collection expressions have nothing to do with threading — this is purely about how the collection is constructed on the current thread.

Why D is incorrect: Type checking still fully applies — the elements must be compatible with the target collection's element type.

Reinforcement: The performance benefit comes from target-type-aware code generation at compile time, not from skipping any safety checks.

You can now build and combine collections with one consistent, readable syntax across arrays, lists, spans, and more.


dotnetmadeeasy.com — Learn C# and .NET, the right way.