C# 12 Language Features: Primary Constructors, Collections & More
26 min read
Intermediate
C# 12 focuses on practical productivity. You get primary constructors for classes and structs, collection expressions for clean initialization, default lambda parameters for simpler callbacks, and type aliases for any type to tame long generic names. These changes cut boilerplate and make intent obvious—especially on .NET 8.
In this tutorial you’ll turn each feature on, see tight before-and-after examples, and learn when to adopt it (and when not to). We’ll cover project setup, migration tips from older code, and light performance notes so you can roll C# 12 into real projects with confidence.
What's New in C# 12
C# 12 shipped with .NET 8 in November 2023 and focuses on four major features that reduce boilerplate and improve readability. Here's what makes C# 12 worth adopting:
🏗️
Primary Constructors
Define constructor parameters directly in class and struct declarations. No more ceremony for simple initialization patterns.
📦
Collection Expressions
Use [...] syntax with spread operators to create arrays, lists, and spans without verbose initialization code.
⚡
Default Lambda Parameters
Add optional parameters to lambda expressions, making functional code more flexible and reducing overload noise.
🎯
Inline Arrays
Stack-allocated fixed-size buffers for performance-critical code paths where heap allocations would be costly.
Note on Features This guide focuses exclusively on C# 12 GA features. Some commonly discussed features like list patterns actually shipped in C# 11. We'll show how to combine C# 11 and C# 12 features effectively, but we label them accurately.
Setup & Compatibility
C# 12 requires the .NET 8 SDK compiler toolchain. You don't need to target .NET 8 at runtime, but you need the SDK installed to access C# 12 language features.
Tip: Targeting Earlier Runtimes You can set <TargetFramework>net6.0</TargetFramework> or net7.0 while using <LangVersion>12</LangVersion>. Most C# 12 features work on older runtimes, but collection expressions with ReadOnlySpan<T> require runtime library support from .NET 8.
IDE Requirements
Visual Studio 2022: Version 17.8 or later
Visual Studio Code: C# Dev Kit extension (latest)
JetBrains Rider: 2023.3 or later
All three IDEs provide full IntelliSense, refactoring tools, and quick fixes for C# 12 features.
Primary Constructors (Deep Dive)
Primary constructors were available for records in C# 9. C# 12 extends them to classes and structs, giving you concise initialization without the ceremony of traditional constructors.
The Traditional Pattern (C# 11)
C# 11 - Traditional Constructor
public class Person
{
public string Name { get; }
public int Age { get; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
}
With Primary Constructors (C# 12)
C# 12 - Primary Constructor
public class Person(string name, int age)
{
public string Name { get; } = name;
public int Age { get; } = age;
}
The parameters name and age are in scope for the entire class body. You can use them in property initializers, field initializers, and instance methods.
Using Primary Constructor Parameters
Primary Constructor Parameters in Methods
public class Logger(string category)
{
// Parameter 'category' available everywhere
public void Log(string message)
{
Console.WriteLine($"[{category}] {message}");
}
public void LogError(Exception ex)
{
Console.WriteLine($"[{category}] ERROR: {ex.Message}");
}
}
When to Use Primary Constructors
Simple DTOs: Data transfer objects with straightforward initialization
Service wrappers: Classes that hold dependencies and use them throughout
Value objects: Small domain types with a few parameters
When to Avoid Primary Constructors If your class has complex initialization logic, validation, or multiple constructor overloads, stick with traditional constructors. Primary constructors shine with simplicity but add confusion when initialization gets complex.
Primary Constructors vs Records
Record with Primary Constructor
// Record: value equality, immutable by default
public record Point(int X, int Y);
var p1 = new Point(10, 20);
var p2 = new Point(10, 20);
Console.WriteLine(p1 == p2); // True - value equality
Class with Primary Constructor
// Class: reference equality, mutable by default
public class Point(int x, int y)
{
public int X { get; set; } = x;
public int Y { get; set; } = y;
}
var p1 = new Point(10, 20);
var p2 = new Point(10, 20);
Console.WriteLine(p1 == p2); // False - reference equality
Use records when you want value semantics and immutability. Use classes with primary constructors when you need reference semantics or mutable state.
Collection Expressions
Collection expressions use the [...] syntax to create arrays, lists, spans, and other collection types. They replace verbose initialization patterns and work seamlessly with spread operators.
Before Collection Expressions (C# 11)
C# 11 - Verbose Initialization
// Combining collections requires multiple statements
var first = new[] { 1, 2, 3 };
var second = new[] { 4, 5, 6 };
var all = new List<int>(first);
all.AddRange(second);
all.Add(7);
int[] array = all.ToArray();
Performance Benefit Collection expressions can reduce allocations. When the compiler knows the target type, it can pre-allocate the exact size needed instead of growing a collection dynamically.
Default Lambda Parameters
C# 12 adds optional parameter support to lambda expressions. This feature brings lambdas closer to regular methods and reduces the need for multiple lambda overloads.
Basic Syntax
Default Lambda Parameters
// Lambda with optional parameter
var scale = (double x, double factor = 1.0) => x * factor;
Console.WriteLine(scale(10)); // 10 (uses default)
Console.WriteLine(scale(10, 2.5)); // 25 (explicit factor)
LINQ Helpers with Defaults
LINQ with Default Parameters
var numbers = new[] { 1, 2, 3, 4, 5 };
// Filter with optional threshold
var filter = (int value, int min = 0) => value > min;
var filtered = numbers.Where(x => filter(x, 3));
// Result: [4, 5]
var allPositive = numbers.Where(x => filter(x));
// Result: [1, 2, 3, 4, 5] (min defaults to 0)
Minimal API Endpoint Configuration
Minimal API with Lambda Defaults
var app = WebApplication.Create();
// Endpoint with optional page size
app.MapGet("/users", (int page = 1, int pageSize = 20) =>
{
return GetUsers(page, pageSize);
});
// GET /users -> page=1, pageSize=20
// GET /users?page=3 -> page=3, pageSize=20
// GET /users?page=2&pageSize=50 -> page=2, pageSize=50
When to Use Default Lambda Parameters
Configuration helpers: Lambdas that configure options with sensible defaults
LINQ projections: Transformations with optional parameters
Event handlers: Callbacks with optional context
Minimal APIs: Route handlers with optional query parameters
Note on Delegates Default lambda parameters don't change the delegate signature. A lambda with defaults can only be assigned to a delegate type that matches the full parameter list. The defaults are evaluated at the call site, not at the delegate declaration.
Inline Arrays
Inline arrays provide stack-allocated, fixed-size buffers for performance-critical scenarios. They're useful when you need deterministic memory layout without heap allocations.
Declaring an Inline Array
Inline Array Declaration
using System.Runtime.CompilerServices;
[InlineArray(16)]
public struct Buffer16<T>
{
private T _element;
}
// Usage
Buffer16<byte> bytes = default;
// Access as span
Span<byte> span = bytes;
for (int i = 0; i < span.Length; i++)
{
span[i] = (byte)(i * 2);
}
Real-World Example: Parsing Fixed-Size Data
Parsing with Inline Arrays
[InlineArray(32)]
public struct FixedStringBuffer
{
private char _element;
}
public class Parser
{
public string ParseFixedString(ReadOnlySpan<byte> data)
{
FixedStringBuffer buffer = default;
Span<char> chars = buffer;
// Convert bytes to chars
for (int i = 0; i < Math.Min(data.Length, 32); i++)
{
chars[i] = (char)data[i];
}
return new string(chars.Slice(0, data.Length));
}
}
When to Use Inline Arrays
Tight loops: Parsing or processing where allocations hurt
Interop scenarios: Fixed-size buffers for P/Invoke or native code
Embedded systems: Memory-constrained environments
High-frequency paths: Hot code paths measured with BenchmarkDotNet
Measure Before Adopting Inline arrays add complexity. Use them only when profiling shows heap allocations are a bottleneck. For most applications, regular arrays and List<T> are simpler and perform well enough.
// Direct, less ceremony
Span<byte> buffer = stackalloc byte[128];
ProcessData(buffer); // Pass as span
Use inline arrays when you need a reusable type with fixed size. Use stackalloc for one-off local buffers in a single method.
Effective Pattern Matching
C# 11 introduced list patterns. C# 12 doesn't add new pattern matching syntax, but collection expressions combine beautifully with list patterns for cleaner data interrogation.
List Patterns Refresher (C# 11)
List Patterns (C# 11)
static string Describe(int[] items) => items switch
{
[] => "empty",
[var x] => $"one item: {x}",
[.., > 100] => "ends with big number",
[0, .., 0] => "starts and ends with zero",
_ => "something else"
};
Combining with Collection Expressions (C# 12)
List Patterns + Collection Expressions
// Build collections based on pattern matching
int[] ProcessData(int[] input) => input switch
{
[] => [0], // Return default if empty
[var single] => [single, single * 2], // Double single item
[var first, .., var last] => [first, last], // Keep ends
_ => [..input, 999] // Append sentinel
};
Practical Example: Command Parsing
Command Parser with Patterns
record Command(string Name, string[] Args);
Command ParseCommand(string[] parts) => parts switch
{
[] => new Command("help", []),
["help"] => new Command("help", []),
["create", var name] => new Command("create", [name]),
["delete", var name, "force"] => new Command("delete", [name, "force"]),
["list", ..var filters] => new Command("list", [..filters]),
_ => throw new ArgumentException("Unknown command")
};
// Usage
var cmd1 = ParseCommand(["create", "project1"]);
var cmd2 = ParseCommand(["delete", "project2", "force"]);
var cmd3 = ParseCommand(["list", "active", "recent"]);
Pattern Matching Best Practices
Be specific first: Order patterns from most specific to most general
Use discard (_): For elements you don't care about
Leverage guards: Add when clauses for complex conditions
Combine features: Mix list patterns with property patterns and type patterns
Pattern Matching + Collection Expressions This combination shines in data pipelines, parsers, and state machines where you interrogate collections and build new ones based on structure rather than element-by-element iteration.
Build & Refactor: C# 11 → C# 12
Let's refactor a small C# 11 Todo application to use C# 12 features. This shows the before-and-after impact in real code.
Before: C# 11 Version
Todo.cs (C# 11)
public class Todo
{
public string Title { get; }
public bool IsComplete { get; }
public Todo(string title, bool isComplete = false)
{
Title = title;
IsComplete = isComplete;
}
}
public class TodoService
{
private readonly List<Todo> _items = new();
public void AddTodos(IEnumerable<Todo> todos)
{
_items.AddRange(todos);
}
public List<Todo> GetActive()
{
return _items.Where(t => !t.IsComplete).ToList();
}
public List<Todo> GetAll()
{
var defaults = new List<Todo>
{
new Todo("Welcome", true)
};
defaults.AddRange(_items);
return defaults;
}
}
After: C# 12 Refactored Version
Todo.cs (C# 12)
// Primary constructor eliminates ceremony
public class Todo(string title, bool isComplete = false)
{
public string Title { get; } = title;
public bool IsComplete { get; } = isComplete;
}
public class TodoService
{
private readonly List<Todo> _items = [];
// Collection expression for empty initialization
public void AddTodos(IEnumerable<Todo> todos)
{
_items.AddRange(todos);
}
public List<Todo> GetActive()
{
return _items.Where(t => !t.IsComplete).ToList();
}
// Collection expression with spread
public List<Todo> GetAll()
{
Todo defaultTodo = new("Welcome", true);
return [defaultTodo, .._items];
}
}
What Changed
Primary constructor:Todo class is now more concise
Collection expression:[] replaces new List<Todo>()
The refactored version has fewer lines, clearer intent, and identical behavior. This pattern scales across your codebase.
Performance & Best Practices
C# 12 features offer performance benefits when used appropriately. Here's how to get the most out of them.
Collection Expressions: Allocation Reductions
Collection expressions can pre-allocate exact sizes when the compiler knows all elements:
Optimized Allocations
// Compiler knows exact size: 7 elements
int[] numbers = [1, 2, 3, 4, 5, 6, 7];
// Single allocation for exact size
// Dynamic size: compiler allocates and may grow
int[] combined = [..first, ..second, ..third];
// Still better than List.AddRange in many cases
Primary Constructors: No Performance Impact
Primary constructors compile to the same IL as traditional constructors. Use them freely for readability without worrying about performance.
Inline Arrays: Measure Before Using
BenchmarkDotNet Template
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class BufferBenchmarks
{
[Benchmark]
public int UseArray()
{
int[] buffer = new int[128];
return ProcessArray(buffer);
}
[Benchmark]
public int UseInlineArray()
{
Buffer128 buffer = default;
return ProcessInline(buffer);
}
private int ProcessArray(int[] data) => data.Sum();
private int ProcessInline(Buffer128 data)
{
Span<int> span = data;
int sum = 0;
foreach (var x in span) sum += x;
return sum;
}
}
[InlineArray(128)]
struct Buffer128 { private int _element; }
Best Practice Guidelines
Primary constructors: Use for simple initialization. Avoid for complex validation or multiple overloads.
Collection expressions: Prefer them for composition. They're clearer and often faster.
Default lambdas: Use for configurability. Don't overuse when simple overloads suffice.
Inline arrays: Adopt only after profiling shows benefit. Stack overflow risks exist with large sizes.
Stack Overflow Risk Inline arrays live on the stack. Declaring a Buffer<1024*1024> will overflow the stack. Keep inline arrays small (typically under 1KB) or allocate on the heap when needed.
Profiling Tools
BenchmarkDotNet: For micro-benchmarking hot paths
dotnet-trace: For capturing CPU and allocation traces
dotnet-counters: For live performance metrics
PerfView: For deep Windows performance analysis
Migration from C# 11
Upgrading to C# 12 is straightforward. Most code runs unchanged, and you can adopt new features incrementally.
Clone and run complete examples demonstrating C# 12 features:
Clone Repository
git clone https://github.com/dotnet-guide-com/tutorials.git
cd tutorials/csharp-language
# Run primary constructors demo
cd PrimaryConstructorsDemo
dotnet run
# Run collection expressions demo
cd ../CollectionsDemo
dotnet run
# Run refactor example
cd ../RefactorApp
dotnet run
Each project includes a README with explanations and runnable code.
You need a compiler and SDK that supports C# 12, which shipped with the .NET 8 toolchain. You can target earlier .NET versions while using C# 12 features by setting LangVersion in your project file, but some features may have runtime requirements.
Can I mix C# 11 and C# 12 features in the same project?
Yes. The project's LangVersion setting applies globally, so once you set it to 12, you can use both C# 11 and C# 12 features throughout your codebase. There are no conflicts between versions.
What's the difference between primary constructors and records?
Records imply value semantics with built-in equality and immutability by default. Primary constructors in C# 12 are now general-purpose for classes and structs, giving you concise initialization without the value-type behavior of records.
Which IDE versions support C# 12?
Visual Studio 2022 version 17.8 or later, Visual Studio Code with the C# Dev Kit extension, and JetBrains Rider 2023.3 or later all support C# 12 features with full IntelliSense and refactoring tools.
What's the biggest time-saver in C# 12?
Collection expressions save the most time when composing, merging, or transforming data. They eliminate verbose initialization code and work seamlessly with arrays, lists, spans, and other collection types, reducing both boilerplate and allocations.