Collection Expressions
Create and spread collections using modern unified syntax across types
Collection Expressions in C#
Master modern C# collection initialization with free flashcards and spaced repetition practice. This lesson covers collection expression syntax, spread operators, and type inferenceβessential features for writing cleaner, more maintainable C# code in .NET 8 and beyond.
Welcome to Collection Expressions π»
Collection expressions represent one of the most significant syntactic improvements in modern C#. Introduced in C# 12 (.NET 8), they provide a unified, concise syntax for creating and initializing collections of all types. Before collection expressions, C# developers had to remember different initialization syntaxes for arrays, lists, spans, and other collection types. Now, a single, elegant syntax works across the board.
Why does this matter? Collection expressions reduce cognitive load, minimize boilerplate code, and make your intentions clearer. Instead of wrestling with new[], new List<T>(), or ToArray() conversions, you can focus on what matters: the data itself.
Core Concepts π―
The Fundamental Syntax
Collection expressions use square brackets [] to define collections. This simple syntax replaces multiple older patterns:
// Old way - arrays
int[] numbers = new int[] { 1, 2, 3 };
// Old way - lists
List<int> numbers = new List<int> { 1, 2, 3 };
// New way - collection expressions (works for both!)
int[] arrayNumbers = [1, 2, 3];
List<int> listNumbers = [1, 2, 3];
The target type (what variable type you're assigning to) determines what collection gets created. This is called target-typed initialization.
Supported Collection Types
Collection expressions work with a wide range of types:
| Collection Type | Example Initialization | Use Case |
|---|---|---|
| Arrays | int[] arr = [1, 2, 3]; | Fixed-size sequences |
| Lists | List<string> lst = ["a", "b"]; | Dynamic collections |
| Spans | Span<int> span = [1, 2, 3]; | Stack-allocated data |
| ReadOnlySpan | ReadOnlySpan<int> roSpan = [1, 2, 3]; | Immutable stack data |
| ImmutableArray | ImmutableArray<int> imm = [1, 2, 3]; | Thread-safe collections |
| Custom Collections | Any type with appropriate methods | Domain-specific structures |
π‘ Pro Tip: Collection expressions generate optimized code. For arrays and spans, the compiler can allocate them inline without heap allocations in many cases!
Empty Collections
Creating empty collections has never been simpler:
// Old ways
int[] empty1 = new int[0];
int[] empty2 = Array.Empty<int>();
List<int> empty3 = new List<int>();
// New way - one syntax for all
int[] emptyArray = [];
List<int> emptyList = [];
Span<int> emptySpan = [];
The empty collection expression [] is particularly elegant and requires no type argumentsβthe target type provides all necessary information.
The Spread Operator π
The spread operator (..) is the most powerful feature of collection expressions. It "spreads" elements from one collection into another, enabling elegant composition patterns.
Basic Spreading
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
// Combine collections with spread
int[] combined = [..first, ..second];
// Result: [1, 2, 3, 4, 5, 6]
// Mix literals and spreads
int[] mixed = [0, ..first, 99, ..second, 100];
// Result: [0, 1, 2, 3, 99, 4, 5, 6, 100]
The spread operator works with any enumerable typeβarrays, lists, LINQ queries, custom collections, even generators:
List<string> names = ["Alice", "Bob"];
IEnumerable<string> moreNames = GetNamesFromDatabase();
string[] allNames = [..names, "Charlie", ..moreNames];
Spread with LINQ π
Collection expressions integrate beautifully with LINQ:
int[] numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Filter and combine in one expression
int[] evensAndLargeOdds = [
..numbers.Where(n => n % 2 == 0),
..numbers.Where(n => n % 2 == 1 && n > 5)
];
// Result: [2, 4, 6, 8, 10, 7, 9]
No more .ToArray() or .ToList() calls cluttering your code! The collection expression handles conversion automatically.
Practical Spread Patterns
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β SPREAD OPERATOR PATTERNS β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ β β β Pattern 1: Concatenation β β [..collection1, ..collection2] β β β β β Combines multiple collections β β β β Pattern 2: Insertion β β [..start, newItem, ..end] β β β β β Adds elements in middle β β β β Pattern 3: Conditional Inclusion β β [..baseItems, ..condition ? extras : []] β β β β β Optionally includes items β β β β Pattern 4: Flattening β β [..collection.SelectMany(x => x.Items)] β β β β β Flattens nested collections β β β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Type Inference and Natural Types π§
Collection expressions support natural type inference, meaning the compiler can deduce the collection type from context:
// Explicit type
List<int> explicitList = [1, 2, 3];
// Inferred as int[] when passed to method
ProcessNumbers([1, 2, 3]);
void ProcessNumbers(int[] numbers) { /* ... */ }
// Inferred from target in assignment
var inferredArray = [1, 2, 3]; // int[] (default target type)
Target-Typed Flexibility
The same collection expression can initialize different collection types:
int[] asArray = [1, 2, 3]; // Array
List<int> asList = [1, 2, 3]; // List
Span<int> asSpan = [1, 2, 3]; // Span
ImmutableArray<int> asImmutable = [1, 2, 3]; // ImmutableArray
IEnumerable<int> asEnumerable = [1, 2, 3]; // IEnumerable
This context-aware flexibility means you write the data once, and the compiler adapts it to your needs.
Common Element Type Inference
When you mix types, the compiler infers a common base type:
// Infers object[] because there's no common specific type
var mixed = [1, "hello", 3.14];
// Infers Animal[] if Dog and Cat both inherit from Animal
var animals = [new Dog(), new Cat(), new Dog()];
// Infers IComparable[] as common interface
var comparables = [42, "text", 3.14];
π‘ Best Practice: Use explicit types when clarity matters. Type inference is powerful but can sometimes produce unexpected base types.
Detailed Examples π
Example 1: Building Configuration Options
Imagine building a configuration system where different environments need different settings:
public class AppConfig
{
public static string[] GetAllowedOrigins(string environment)
{
string[] baseOrigins = ["https://myapp.com"];
return environment switch
{
"Development" => [
..baseOrigins,
"http://localhost:3000",
"http://localhost:5000"
],
"Staging" => [
..baseOrigins,
"https://staging.myapp.com"
],
"Production" => baseOrigins,
_ => []
};
}
}
// Usage
var devOrigins = AppConfig.GetAllowedOrigins("Development");
// Result: ["https://myapp.com", "http://localhost:3000", "http://localhost:5000"]
Why this works well: Collection expressions make it easy to conditionally compose configurations. The spread operator avoids repetition, and the switch expression with collection expressions is highly readable.
Example 2: Data Transformation Pipeline
Processing data from multiple sources becomes elegant:
public class DataProcessor
{
public static int[] ProcessData(
int[] inputData,
bool includeNegatives,
bool includeStatistics)
{
// Transform the input
var processed = inputData
.Where(x => includeNegatives || x >= 0)
.Select(x => x * 2);
// Compose result with optional statistics
return [
..processed,
..includeStatistics
? [processed.Min(), processed.Max(), (int)processed.Average()]
: []
];
}
}
// Usage
int[] input = [-5, 3, 7, -2, 10];
var result = DataProcessor.ProcessData(input, false, true);
// Filters negatives, doubles values, adds min/max/avg statistics
Key insight: The conditional spread ..condition ? items : [] pattern lets you optionally include elements without if-else blocks or separate method calls.
Example 3: Building Test Data
Collection expressions shine in testing scenarios:
public class UserTestData
{
private static readonly User[] StandardUsers = [
new User("Alice", "alice@test.com"),
new User("Bob", "bob@test.com")
];
private static readonly User[] AdminUsers = [
new User("Admin1", "admin1@test.com", isAdmin: true)
];
public static User[] GetTestUsers(bool includeAdmins, int extraUserCount)
{
var extraUsers = Enumerable.Range(1, extraUserCount)
.Select(i => new User($"User{i}", $"user{i}@test.com"));
return [
..StandardUsers,
..includeAdmins ? AdminUsers : [],
..extraUsers
];
}
}
// Usage in tests
[Test]
public void TestWithVariousUsers()
{
var users = UserTestData.GetTestUsers(includeAdmins: true, extraUserCount: 3);
// Gets 2 standard + 1 admin + 3 generated = 6 users total
Assert.AreEqual(6, users.Length);
}
Testing benefit: Compose test data flexibly without complex builders or setup methods. The declarative nature makes test intent crystal clear.
Example 4: Performance-Critical Span Usage
For high-performance scenarios, collection expressions work with stack-allocated spans:
public static class MathHelpers
{
public static double CalculateWeightedAverage(
ReadOnlySpan<double> values,
ReadOnlySpan<double> weights)
{
if (values.Length != weights.Length)
throw new ArgumentException("Lengths must match");
double sum = 0;
double weightSum = 0;
for (int i = 0; i < values.Length; i++)
{
sum += values[i] * weights[i];
weightSum += weights[i];
}
return sum / weightSum;
}
}
// Usage with collection expressions - no heap allocation!
public void ProcessSensorData()
{
ReadOnlySpan<double> temperatures = [20.5, 21.3, 19.8, 22.1];
ReadOnlySpan<double> confidenceWeights = [0.9, 1.0, 0.7, 0.95];
double avgTemp = MathHelpers.CalculateWeightedAverage(
temperatures,
confidenceWeights
);
}
Performance note: When used with Span<T> or ReadOnlySpan<T>, collection expressions can allocate directly on the stack, avoiding garbage collection overheadβcrucial for high-frequency operations.
Common Mistakes β οΈ
Mistake 1: Forgetting Type Context
// β ERROR: Cannot infer type without context
var mystery = []; // Compiler error!
// β
CORRECT: Provide type context
int[] empty = [];
var emptyList = new List<int>(); // Can't use [] here without cast
List<int> properEmpty = []; // This works!
Lesson: Empty collection expressions [] always need type context from assignment target, parameter, or explicit cast.
Mistake 2: Modifying Immutable Results
ReadOnlySpan<int> numbers = [1, 2, 3];
// β ERROR: Cannot modify ReadOnlySpan
numbers[0] = 10; // Compilation error
// β
CORRECT: Use mutable Span if you need modification
Span<int> mutableNumbers = [1, 2, 3];
mutableNumbers[0] = 10; // This works
Lesson: Pay attention to whether your target type is read-only. Collection expressions respect immutability contracts.
Mistake 3: Unnecessary ToArray/ToList Calls
var query = data.Where(x => x > 10).Select(x => x * 2);
// β REDUNDANT: No need for ToArray with collection expressions
int[] result1 = [..query.ToArray()];
// β
CORRECT: Spread handles enumerable directly
int[] result2 = [..query];
Lesson: The spread operator works with any IEnumerable<T>. Don't materialize collections unnecessarily.
Mistake 4: Spreading Null Collections
int[]? maybeNull = GetOptionalData();
// β RUNTIME ERROR: Spreading null throws NullReferenceException
int[] result1 = [1, 2, ..maybeNull]; // Crashes if maybeNull is null!
// β
CORRECT: Handle null explicitly
int[] result2 = [1, 2, ..(maybeNull ?? [])];
// Or
int[] result3 = [1, 2, ..(maybeNull ?? Array.Empty<int>())];
Lesson: Always null-check before spreading nullable collections. Use the null-coalescing operator ?? for concise safety.
Mistake 5: Type Mismatch in Mixed Collections
// β SUBTLE BUG: Creates object[] instead of int[]
var mixed = [1, 2, GetValue()]; // If GetValue() returns object
// β
CORRECT: Ensure consistent types or explicit cast
int[] typedResult = [1, 2, (int)GetValue()];
Lesson: When mixing sources, the compiler infers the most general common type. Be explicit when you want a specific type.
Memory Aid π§
Use this mnemonic to remember collection expression features:
BEST collections:
- Brackets: Square brackets
[]define collections - Enumerable: Works with any IEnumerable
- Spread:
..operator composes collections - Target-typed: Type determined by context
Key Takeaways π
π Collection Expressions Quick Reference
| Feature | Syntax | Example |
|---|---|---|
| Basic creation | [] | int[] nums = [1, 2, 3]; |
| Empty collection | [] | List<string> empty = []; |
| Spread operator | .. | [..arr1, ..arr2] |
| Mixed literals/spread | [literal, ..collection] | [0, ..nums, 99] |
| Conditional spread | ..condition ? items : [] | [..base, ..flag ? extra : []] |
| LINQ integration | [..query] | [..nums.Where(x => x > 5)] |
β Benefits:
- Unified syntax across all collection types
- Reduced boilerplate and cognitive load
- Optimized compiler output
- Better readability and maintainability
- Seamless LINQ integration
β οΈ Remember:
- Empty
[]needs type context - Null-check before spreading nullable collections
- Target type determines what collection is created
- Use explicit types when clarity matters
Further Study π
Ready to dive deeper? Explore these resources:
Microsoft Official Documentation: Collection expressions - C# language reference - Comprehensive guide with all technical details
C# 12 What's New: What's new in C# 12 - Context on collection expressions within the broader C# 12 feature set
Performance Deep Dive: Performance improvements in .NET 8 - Collection expressions - How collection expressions optimize your code under the hood
Congratulations! π You now understand collection expressions, one of C#'s most elegant modern features. Practice using them in your own code to see how they simplify collection initialization and composition. The more you use them, the more natural they'll feel, and you'll wonder how you ever lived without them!