✨ Hands-On Tutorial

C# 12 Language Features: Primary Constructors, Collections & More

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.

Check Your SDK Version

Terminal
dotnet --version
# Should show 8.0.x or higher

If you see a version below 8.0, download the latest .NET 8 SDK from dotnet.microsoft.com/download.

Enable C# 12 in Your Project

Add or update the LangVersion property in your .csproj file:

YourProject.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <LangVersion>12</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

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();

With Collection Expressions (C# 12)

C# 12 - Collection Expressions
// Spread operators merge collections inline
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];

int[] all = [.. first, .. second, 7];
// Result: [1, 2, 3, 4, 5, 6, 7]

Target-Typed Collection Expressions

Collection expressions adapt to the target type. The same syntax works for arrays, lists, spans, and immutable collections:

Target-Typed Collections
// Array
int[] array = [1, 2, 3];

// List
List<int> list = [1, 2, 3];

// Span (stack-allocated)
Span<int> span = [1, 2, 3];

// ReadOnlySpan (requires .NET 8 runtime)
ReadOnlySpan<int> readOnlySpan = [1, 2, 3];

// ImmutableArray
ImmutableArray<int> immutable = [1, 2, 3];

Spread Operators in Action

Spread Examples
int[] numbers = [1, 2, 3];
int[] moreNumbers = [4, 5];

// Merge multiple collections
int[] combined = [..numbers, ..moreNumbers, 6, 7];
// [1, 2, 3, 4, 5, 6, 7]

// Prepend elements
int[] withPrefix = [0, ..numbers];
// [0, 1, 2, 3]

// Insert in middle
int[] inserted = [..numbers[..2], 99, ..numbers[2..]];
// [1, 2, 99, 3]

Real-World Example: Building Query Filters

Dynamic Filter Building
string[] baseFilters = ["active=true", "deleted=false"];
string[] userFilters = GetUserFilters(); // From request

// Combine with optional filters
string[] allFilters = includeArchived 
    ? [..baseFilters, ..userFilters, "archived=true"]
    : [..baseFilters, ..userFilters];

string queryString = string.Join("&", allFilters);

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.

Inline Arrays vs stackalloc

Inline Array Approach
// Type-safe, reusable
[InlineArray(128)]
public struct ByteBuffer
{
    private byte _element;
}

ByteBuffer buffer = default;
ProcessData(buffer); // Pass as struct
stackalloc Approach
// 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>()
  • Spread operator: [defaultTodo, .._items] replaces multi-line initialization

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.

Migration Checklist

  • ✅ Install .NET 8 SDK (or later)
  • ✅ Update <LangVersion>12</LangVersion> in .csproj
  • ✅ Update analyzers and IDE extensions
  • ✅ Run full test suite
  • ✅ Review compiler warnings for deprecated APIs
  • ✅ Update CI/CD pipelines to use .NET 8 SDK

Project File Changes

Project.csproj (C# 11)
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <LangVersion>11</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Project.csproj (C# 12)
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <LangVersion>12</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

Breaking Changes

C# 12 has no significant breaking changes. Edge cases to watch:

  • Reflection: Primary constructor parameters aren't properties unless you declare them. Reflection code expecting properties may break.
  • Serialization: JSON serializers need properties or fields. Primary constructor parameters alone aren't serialized.
  • Inline arrays: If you use reflection or IL emit on inline arrays, verify behavior.

Incremental Adoption Strategy

  1. Week 1: Update SDK and set LangVersion. Run tests.
  2. Week 2: Adopt collection expressions in new code.
  3. Week 3: Refactor simple classes to primary constructors.
  4. Week 4: Review performance hotspots for inline array opportunities.

You don't need to refactor everything immediately. Adopt C# 12 features as you touch existing code or write new modules.

Next Steps & Resources

You've learned the core C# 12 features. Here's where to go next:

Related Tutorials on dotnet-guide.com

Official Documentation

GitHub Example Projects

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.

Community Resources

Frequently Asked Questions

Do I need .NET 8 to use C# 12?

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.