Articles › C#

Modern C# Features: Records, Pattern Matching, Primary Constructors and More

A practical tour of modern C# (9 to 13): records, init-only properties, pattern matching, switch expressions, primary constructors, collection expressions and raw strings.

C# has evolved dramatically. Code written with C# 12 and 13 looks very different from C# 7 — shorter, safer and more expressive. If you learned C# a few years ago, this tour shows the features worth adopting today, with examples you can drop straight into your projects.

Records: data with value semantics

A record is a type designed for data. The compiler generates value-based equality, a readable ToString(), deconstruction and non-destructive mutation.

public record Money(decimal Amount, string Currency);

var a = new Money(100, "USD");
var b = new Money(100, "USD");

Console.WriteLine(a == b);   // True — compared by value, not reference
Console.WriteLine(a);        // Money { Amount = 100, Currency = USD }

var c = a with { Amount = 250 };   // copy with one change
var (amount, currency) = c;        // deconstruction

Use records for DTOs, API requests/responses, value objects and messages. record struct gives you the same benefits for small value types.

Tip: Records are great for EF Core DTOs, but entities tracked by EF Core usually work better as classes, because their identity is their key, not their values.

init-only properties and required members

public class Settings
{
    public required string ApiUrl { get; init; }
    public int TimeoutSeconds { get; init; } = 30;
}

var s = new Settings { ApiUrl = "https://api.example.com" };
// s.ApiUrl = "...";       ❌ can't change after initialization
// new Settings();         ❌ compiler error: ApiUrl is required

init makes objects immutable after construction; required forces callers to set important properties.

Pattern matching

Pattern matching lets you test the shape of data and extract values in one step.

object value = 42;

if (value is int number and > 0)
    Console.WriteLine($"Positive int: {number}");

// Property patterns
if (order is { Status: OrderStatus.Paid, Total: > 1_000_000 })
    ApplyVipDiscount(order);

// Null checks
if (customer is not null) { /* ... */ }

Switch expressions

Switch expressions replace verbose switch statements with a concise, expression-based form:

decimal Shipping(Order order) => order switch
{
    { Country: "IR", Total: >= 2_000_000 } => 0,
    { Country: "IR" }                       => 60_000,
    { IsExpress: true }                     => 25,
    _                                       => 12
};

Combined patterns make business rules read like a specification:

string Classify(int temperature) => temperature switch
{
    < 0            => "freezing",
    >= 0 and < 15  => "cold",
    >= 15 and < 28 => "pleasant",
    _              => "hot"
};

List patterns (C# 11)

string Describe(int[] numbers) => numbers switch
{
    []              => "empty",
    [var single]    => $"one item: {single}",
    [1, ..]         => "starts with 1",
    [.., var last]  => $"ends with {last}"
};

Primary constructors (C# 12)

Classes and structs can now declare constructor parameters directly in the type declaration — ideal for dependency injection:

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

    public OrderService(IOrderRepository orders, ILogger<OrderService> logger)
    {
        _orders = orders;
        _logger = logger;
    }
}

// C# 12
public class OrderService(IOrderRepository orders, ILogger<OrderService> logger)
{
    public async Task<Order?> GetAsync(int id) =>
        await orders.FindAsync(id);
}

Note: primary constructor parameters in classes are captured variables, not readonly fields. If immutability matters, assign them to a readonly field explicitly.

Collection expressions (C# 12)

A single, uniform syntax to create arrays, lists and spans:

int[] primes = [2, 3, 5, 7];
List<string> tags = ["csharp", "dotnet"];
Span<byte> buffer = [0, 0, 0, 0];

int[] more = [..primes, 11, 13];   // spread
List<int> empty = [];

Raw string literals (C# 11)

Perfect for JSON, SQL and regular expressions — no escaping:

var json = """
    {
      "name": "Ali",
      "skills": ["C#", "React"]
    }
    """;

var name = "Ali";
var template = $$"""
    { "greeting": "Hello, {{name}}" }
    """;

File-scoped namespaces and global usings

namespace Shop.Application.Orders;   // no extra indentation

// GlobalUsings.cs
global using Microsoft.EntityFrameworkCore;
global using Shop.Domain.Entities;

With <ImplicitUsings>enable</ImplicitUsings>, common namespaces are imported automatically.

Nullable reference types

Enable them in every project:

<Nullable>enable</Nullable>
public string Name { get; set; } = string.Empty;  // never null
public string? MiddleName { get; set; }           // may be null

int length = person.MiddleName?.Length ?? 0;

The compiler warns you before a NullReferenceException happens at runtime.

Other small wins

// Target-typed new
Dictionary<string, List<int>> map = new();

// Static lambdas (no accidental captures)
Func<int, int> square = static x => x * x;

// Interpolated string handlers and utf8 literals
ReadOnlySpan<byte> header = "Content-Type"u8;

// Params collections (C# 13)
void Log(params ReadOnlySpan<string> messages) { }

A before/after example

// Old style
public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

public string GetLabel(ProductDto p)
{
    if (p == null) return "unknown";
    if (p.Price == 0) return "free";
    if (p.Price < 100) return "cheap";
    return "premium";
}

// Modern C#
public record ProductDto(int Id, string Name, decimal Price);

public static string GetLabel(ProductDto? p) => p switch
{
    null              => "unknown",
    { Price: 0 }      => "free",
    { Price: < 100 }  => "cheap",
    _                 => "premium"
};

Summary

Modern C# lets you express intent with far less ceremony: records for data, init and required for safe construction, pattern matching and switch expressions for clear business rules, primary constructors for DI, collection expressions and raw strings for literals, and nullable reference types for safety. Adopt them gradually — your code will become shorter and more correct at the same time.

FAQ

Do I need the latest .NET to use these features?

Most language features depend on the C# version, which is tied to the target framework (.NET 8 → C# 12, .NET 9 → C# 13). Some features work on older targets by setting LangVersion, but runtime-dependent ones don't.

Should I convert all classes to records?

No. Use records for data and value objects. Keep classes for services and entities with identity and behavior.

Are switch expressions slower than if statements?

No. The compiler generates efficient code — often identical to hand-written conditions.