Contents

What changed and in which version

Feature Version
Primary constructors (classes) C# 12
Collection expressions C# 12
ref readonly parameters C# 12
params collections C# 13
lock object (System.Threading.Lock) C# 13
field keyword C# 14
Extension members C# 14
null-conditional assignment (??=) improvements C# 14

C# 14 ships with .NET 10 (November 2025). C# 12 — with .NET 8, C# 13 — with .NET 9. If your project is still on .NET Framework, start with migrating to .NET 10.

Primary Constructors (C# 12)

Before C# 12, every class had the same boilerplate: field, constructor, assignment.

// Before
public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly ILogger<OrderService> _logger;

    public OrderService(IOrderRepository repo, ILogger<OrderService> logger)
    {
        _repo = repo;
        _logger = logger;
    }
}

// After (C# 12)
public class OrderService(IOrderRepository repo, ILogger<OrderService> logger)
{
    public async Task<Order?> GetAsync(int id)
    {
        logger.LogInformation("Fetching order {Id}", id);
        return await repo.GetByIdAsync(id);
    }
}

Primary constructor parameters are available throughout the class body as captured variables. Important: these are not fields, but captured variables — they are not on this.

When to use: services with DI, repositories, any class where the constructor only assigns dependencies.

When not to use: if you need logic in the constructor — checks, transformations — better keep an explicit constructor.

Collection Expressions (C# 12)

A unified syntax for any collection:

// Array
int[] nums = [1, 2, 3, 4, 5];

// List<T>
List<string> names = ["Alice", "Bob", "Charlie"];

// Spread operator (..) — combine collections
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] all = [..first, ..second]; // [1, 2, 3, 4, 5, 6]

// In method parameters
ProcessItems(["item1", "item2"]);

// Empty collection
IReadOnlyList<int> empty = [];

The compiler picks the optimal way to build the collection. [] for empty collections is more efficient than new List<T>() or Array.Empty<T>().

Records (C# 9+, improvements in C# 12-14)

Records are immutable value-like types for data. In the .NET stack they replace DTO classes:

// Record class (reference type)
public record CreateOrderRequest(
    int UserId,
    List<OrderItem> Items,
    string? PromoCode
);

// Record struct (value type, stack allocation)
public readonly record struct Money(decimal Amount, string Currency);

// With-expression for changes
var original = new Money(100m, "USD");
var doubled = original with { Amount = 200m };

// Destructuring
var (amount, currency) = original;

When to use records:

  • Request/Response DTOs in an API
  • Value objects in the domain model (Money, Email, Address)
  • Configuration objects

Don't use records for: EF Core entities (issues with change tracking), objects with mutable state.

Pattern Matching — the full arsenal

// Switch expression with patterns (C# 8-12)
public decimal CalcDiscount(Customer customer) => customer switch
{
    { IsVip: true, TotalOrders: > 100 } => 0.20m,
    { IsVip: true }                      => 0.10m,
    { TotalOrders: > 50 }               => 0.05m,
    _                                    => 0m
};

// List patterns (C# 11)
public string Describe(int[] arr) => arr switch
{
    []          => "empty",
    [var x]     => $"one element: {x}",
    [var x, var y] => $"two elements: {x}, {y}",
    [var first, .., var last] => $"starts {first}, ends {last}"
};

// Type patterns with when
public string Process(object obj) => obj switch
{
    string s when s.Length > 100 => "long string",
    string s                      => $"string: {s}",
    int n when n < 0              => "negative",
    int n                         => $"positive: {n}",
    null                          => "null",
    _                             => "unknown"
};

params collections (C# 13)

Previously params only worked with arrays. Now it works with any collection:

// C# 12 and earlier — array only
public void Log(string message, params object[] args) { ... }

// C# 13 — any collection
public void Log(string message, params IEnumerable<object> args) { ... }
public void AddTags(params ReadOnlySpan<string> tags) { ... } // no heap allocation

// Call site is unchanged
Log("User {0} logged in", userId);
AddTags("backend", "api", "v2");

params ReadOnlySpan<T> is especially useful for hot paths — no heap allocations.

field keyword (C# 14)

Lets you add logic to an auto-property without declaring an explicit backing field:

// Before — had to declare the field manually
private string _name = string.Empty;
public string Name
{
    get => _name;
    set => _name = value?.Trim() ?? string.Empty;
}

// After (C# 14)
public string Name
{
    get;
    set => field = value?.Trim() ?? string.Empty;
}

// With validation
public int Age
{
    get;
    set
    {
        if (value < 0 || value > 150)
            throw new ArgumentOutOfRangeException(nameof(Age));
        field = value;
    }
}

Extension Members (C# 14)

The biggest C# 14 feature. Lets you add not just methods, but also properties and indexers, to existing types:

// Before: extension methods only
public static class StringExtensions
{
    public static bool IsValidEmail(this string s) =>
        s.Contains('@') && s.Contains('.');
}

// After: extension block with methods AND properties (C# 14)
extension(string s)
{
    // Extension property
    public bool IsValidEmail => s.Contains('@') && s.Contains('.');
    public bool IsEmpty => string.IsNullOrWhiteSpace(s);

    // Extension method
    public string Truncate(int maxLength) =>
        s.Length <= maxLength ? s : s[..maxLength] + "...";
}

// Usage
string email = "[email protected]";
if (email.IsValidEmail)
{
    Console.WriteLine(email.Truncate(20));
}

Extensions for interfaces — handy for enriching without inheritance:

extension(IEnumerable<Order> orders)
{
    public decimal TotalRevenue => orders.Sum(o => o.Amount);
    public IEnumerable<Order> ForUser(int userId) =>
        orders.Where(o => o.UserId == userId);
}

// Usage
decimal revenue = dbOrders.TotalRevenue;
var myOrders = dbOrders.ForUser(currentUserId);

Null Safety — new capabilities

// Null-conditional assignment (C# 14)
// Assigns only if the left side is not null
user?.Name = "Alice"; // assigns only if user != null

// Compound null-coalescing
config.Timeout ??= TimeSpan.FromSeconds(30); // already in C# 8
config.BaseUrl ??= "https://api.example.com";

// Required members (C# 11) — initialization guarantee
public class UserDto
{
    public required string Email { get; init; }
    public required string Name { get; init; }
    public string? Phone { get; init; } // optional
}

// The compiler enforces initialization of required properties
var dto = new UserDto { Email = "[email protected]", Name = "Alice" }; // OK
var dto2 = new UserDto { Email = "[email protected]" }; // COMPILE ERROR

Raw String Literals (C# 11)

// Before — escaping nightmare
string json = "{\"name\": \"Alice\", \"age\": 30}";
string path = "C:\\Users\\Alice\\Documents";

// Now — raw string literals
string json = """
    {
        "name": "Alice",
        "age": 30
    }
    """;

// With interpolation (double $$ to escape {})
string name = "Alice";

string template = $$"""
    {
        "name": "{{name}}",
        "timestamp": "{{DateTime.UtcNow:O}}"
    }
    """;

Especially handy for SQL, JSON templates, regex.

When to apply this in real projects

Right away: primary constructors (less boilerplate), collection expressions (readability), raw string literals (SQL/JSON).

Case by case: records for DTOs and value objects, pattern matching instead of long if-else chains.

C# 14 (only on .NET 10): field keyword — when you need property logic without a full field. Extension members — for enriching library types.

💡 Use the dotnet-upgrade-assistant analyzer and Roslyn analyzers — they'll point out places where new patterns can be applied automatically. Bulk refactoring to the new syntax is a good job for Claude Code with the /csharp-modernize skill.

Share: