Modern C# Language Features: Pattern Matching, Records, and More
Table of Contents
- Pattern Matching (C# 7–13)
- Records (C# 9.0 / 10.0)
requiredMembers (C# 11)- Collection Expressions (C# 12)
- Primary Constructors for Classes (C# 12)
file-Scoped Types (C# 11)global using& Implicit Usings (C# 10)- Raw String Literals (C# 11)
paramsCollections (C# 13)- Other Notable Features
Pattern Matching (C# 7–13)
Pattern matching lets you test a value against a shape, type, or condition — and extract data in the process. It has evolved significantly across C# versions, from simple type checks to complex deconstruction.
Type Patterns (C# 7.0)
// Test and cast in one step
object obj = GetValue();
if (obj is string text)
Console.WriteLine(text.Length); // text is already a string
if (obj is int number and number > 0)
Console.WriteLine($"Positive: {number}");
// Switch with type patterns
string Describe(object value) => value switch
{
int i => $"Integer: {i}",
string s => $"String: {s}",
null => "null",
_ => $"Unknown: {value.GetType().Name}"
};
Property Patterns (C# 8.0)
// Match on properties of an object
string GetShippingCost(Order order) => order switch
{
{ Total: > 100, IsPremium: true } => "Free",
{ Total: > 100 } => "$5.00",
{ Weight: > 50 } => "$25.00",
_ => "$10.00"
};
// Nested property patterns
bool IsLocalCustomer(Order order) => order is
{
Customer.Address.Country: "US",
Customer.Address.State: "CA"
};
Positional Patterns (C# 8.0)
// Works with types that have Deconstruct or are records/tuples
public record Point(int X, int Y);
string Classify(Point point) => point switch
{
(0, 0) => "Origin",
(var x, 0) => $"On X-axis at {x}",
(0, var y) => $"On Y-axis at {y}",
(var x, var y) when x == y => $"On diagonal at ({x}, {y})",
_ => "Elsewhere"
};
Relational & Logical Patterns (C# 9.0)
// Relational: <, >, <=, >=
string GetTemperatureCategory(double temp) => temp switch
{
< 0 => "Freezing",
>= 0 and < 15 => "Cold",
>= 15 and < 25 => "Pleasant",
>= 25 and < 35 => "Warm",
>= 35 => "Hot"
};
// Logical: and, or, not
bool IsValidAge(int age) => age is >= 0 and <= 150;
bool IsNotNull(object? obj) => obj is not null;
bool IsWeekend(DayOfWeek day) => day is DayOfWeek.Saturday or DayOfWeek.Sunday;
// Combined
string Classify(int value) => value switch
{
> 0 and < 10 => "Small positive",
>= 10 and < 100 => "Medium positive",
>= 100 => "Large positive",
0 => "Zero",
< 0 => "Negative"
};
List Patterns (C# 11)
// Match against array/list structure
int[] numbers = [1, 2, 3, 4, 5];
var result = numbers switch
{
[] => "Empty",
[var single] => $"Single: {single}",
[var first, .., var last] => $"First: {first}, Last: {last}",
[1, 2, ..] => "Starts with 1, 2",
[.., 4, 5] => "Ends with 4, 5",
};
// Slice pattern with discard
bool IsValid(int[] data) => data is [> 0, .., > 0]; // first and last are positive
// With ReadOnlySpan<char> for string parsing
bool IsCsvHeader(ReadOnlySpan<char> line) => line switch
{
['#', ..] => false, // comment line
['"', .., '"'] => true, // quoted header
_ => true
};
Records (C# 9.0 / 10.0)
Records are reference types (or value types with record struct) that provide value-based equality, immutability, and deconstruction with minimal boilerplate.
// Record class (reference type, C# 9.0)
public record OrderDto(int Id, string CustomerName, decimal Total);
// Record struct (value type, C# 10.0)
public readonly record struct Money(decimal Amount, string Currency);
// What you get for free:
// • Value-based Equals and GetHashCode
// • ToString (e.g., "OrderDto { Id = 1, CustomerName = Alice, Total = 99.99 }")
// • Deconstruction
// • with-expression for non-destructive mutation
var order = new OrderDto(1, "Alice", 99.99m);
var updated = order with { Total = 150m }; // new instance, only Total changed
var (id, name, total) = order; // deconstruction
Record vs Class vs Struct
| Feature | record |
record struct |
class |
struct |
|---|---|---|---|---|
| Equality | Value-based | Value-based | Reference-based | Value-based (slow) |
| Heap/Stack | Heap | Stack | Heap | Stack |
with expression |
✓ | ✓ | ✗ | ✗ |
| Inheritance | ✓ | ✗ | ✓ | ✗ |
ToString |
Auto-generated | Auto-generated | GetType().Name |
GetType().Name |
| Best for | DTOs, events, value objects | Small value objects | Complex entities | Low-level perf |
required Members (C# 11)
Forces callers to set specific properties at construction time — compile-time enforcement.
public class CreateOrderRequest
{
public required string CustomerId { get; init; }
public required List<OrderItemRequest> Items { get; init; }
public string? Notes { get; init; } // optional
}
// ✓ Must set required properties
var request = new CreateOrderRequest
{
CustomerId = "cust-1",
Items = [new("SKU-1", 2)]
};
// ✗ Compile error — CustomerId is required
var bad = new CreateOrderRequest
{
Items = [new("SKU-1", 2)]
}; // CS9035: Required member 'CustomerId' must be set
Use required instead of constructor parameters when you want object-initializer syntax with compile-time safety.
Collection Expressions (C# 12)
A concise syntax for creating collections — works with arrays, List<T>, Span<T>, ImmutableArray<T>, and more.
// Before
int[] numbers = new int[] { 1, 2, 3 };
List<string> names = new List<string> { "Alice", "Bob" };
Span<int> span = stackalloc int[] { 1, 2, 3 };
// After (C# 12)
int[] numbers = [1, 2, 3];
List<string> names = ["Alice", "Bob"];
Span<int> span = [1, 2, 3];
// Empty collections
List<Order> orders = [];
int[] empty = [];
// Spread operator (..) — combine collections
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] combined = [..first, ..second]; // [1, 2, 3, 4, 5, 6]
// Works with any target type that supports collection expressions
ImmutableArray<int> immutable = [1, 2, 3];
HashSet<string> set = ["a", "b", "c"];
Primary Constructors for Classes (C# 12)
Classes can now have primary constructors — parameters are captured and available throughout the class.
// 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?> GetByIdAsync(int id, CancellationToken ct)
{
logger.LogInformation("Fetching order {OrderId}", id);
return await repo.GetByIdAsync(id, ct);
}
}
Note: Primary constructor parameters are not readonly by default — see your clean-code guidelines for the full details.
file-Scoped Types (C# 11)
A type visible only within the file where it's declared — perfect for hiding implementation details.
// OrderService.cs
public class OrderService(IOrderRepository repo)
{
public async Task<OrderResult> ProcessAsync(Order order, CancellationToken ct)
{
var validator = new OrderValidator();
if (!validator.IsValid(order))
return OrderResult.Invalid(validator.Errors);
await repo.SaveAsync(order, ct);
return OrderResult.Success(order);
}
}
// Only visible in this file — won't pollute the namespace
file class OrderValidator
{
public List<string> Errors { get; } = [];
public bool IsValid(Order order)
{
if (order.Total <= 0)
Errors.Add("Total must be positive");
if (string.IsNullOrWhiteSpace(order.CustomerName))
Errors.Add("Customer name is required");
return Errors.Count == 0;
}
}
global using & Implicit Usings (C# 10)
// GlobalUsings.cs — declare once, available everywhere in the project
global using System.Collections.Concurrent;
global using Microsoft.Extensions.Logging;
global using MyApp.Domain.Entities;
global using MyApp.Application.Interfaces;
// Implicit usings (.NET 6+ — enabled by default in .csproj)
// <ImplicitUsings>enable</ImplicitUsings>
// Automatically adds: System, System.Collections.Generic, System.Linq,
// System.Threading.Tasks, System.IO, etc.
Raw String Literals (C# 11)
// Multi-line strings without escape characters
string json = """
{
"name": "Alice",
"age": 30,
"orders": [
{ "id": 1, "total": 99.99 }
]
}
""";
// Interpolated raw strings — use $$ with {{ }}
string name = "Alice";
string jsonTemplate = $$"""
{
"name": "{{name}}",
"timestamp": "{{DateTime.UtcNow:O}}"
}
""";
params Collections (C# 13)
params now works with any collection type, not just arrays.
// Before (C# 1.0 — arrays only)
public void Log(params string[] messages) { }
// After (C# 13 — any collection)
public void Log(params ReadOnlySpan<string> messages)
{
foreach (var msg in messages)
Console.WriteLine(msg);
}
public void Process(params IEnumerable<int> values) { }
public void Build(params List<string> items) { }
// Still called the same way
Log("hello", "world");
Process(1, 2, 3);
💡 params ReadOnlySpan<T> avoids the array allocation of params T[].
Other Notable Features
using Declarations (C# 8.0)
No braces needed — automatically disposed at end of scope.
// Before
using (var stream = File.OpenRead("data.txt"))
{
// use stream
}
// After — disposed when the enclosing block (method, if, etc.) exits
using var stream = File.OpenRead("data.txt");
// use stream
Switch Expressions (C# 8.0)
string result = status switch
{
OrderStatus.Pending => "Processing",
OrderStatus.Shipped => "On the way",
OrderStatus.Delivered => "Complete",
_ => "Unknown"
};
Null-Coalescing Assignment ??= (C# 8.0)
_cache ??= new Dictionary<string, int>();
Target-Typed new (C# 9.0)
Dictionary<string, List<int>> map = new(); // type inferred from left side
File-Scoped Namespaces (C# 10)
// Before
namespace MyApp.Services
{
public class OrderService { }
}
// After — saves one level of indentation
namespace MyApp.Services;
public class OrderService { }
Generic Attributes (C# 11)
// Before — typeof required
[Validator(typeof(OrderValidator))]
public class Order { }
// After — generic attribute
[Validator<OrderValidator>]
public class Order { }
nameof Scope Improvement (C# 11)
// nameof works on method parameters in attributes
[return: NotNullIfNotNull(nameof(input))]
public static string? Process(string? input) => input?.Trim();
Feature Version Quick Reference
| Feature | C# Version | .NET Version |
|---|---|---|
| Pattern matching (basic) | 7.0 | .NET Core 2.0 |
Span<T>, ref struct |
7.2 | .NET Core 2.1 |
| Nullable reference types | 8.0 | .NET Core 3.0 |
using declarations |
8.0 | .NET Core 3.0 |
| Switch expressions | 8.0 | .NET Core 3.0 |
| Default interface methods | 8.0 | .NET Core 3.0 |
IAsyncEnumerable<T> |
8.0 | .NET Core 3.0 |
| Records | 9.0 | .NET 5 |
| Relational / logical patterns | 9.0 | .NET 5 |
Target-typed new |
9.0 | .NET 5 |
global using |
10.0 | .NET 6 |
| File-scoped namespaces | 10.0 | .NET 6 |
| Record structs | 10.0 | .NET 6 |
| Natural lambda types | 10.0 | .NET 6 |
| Raw string literals | 11.0 | .NET 7 |
| List patterns | 11.0 | .NET 7 |
required members |
11.0 | .NET 7 |
file-scoped types |
11.0 | .NET 7 |
| Generic attributes | 11.0 | .NET 7 |
| Static abstract interface members | 11.0 | .NET 7 |
Generic math (INumber<T>) |
11.0 | .NET 7 |
| Primary constructors (classes) | 12.0 | .NET 8 |
Collection expressions [1,2,3] |
12.0 | .NET 8 |
params collections |
13.0 | .NET 9 |
Lock type |
13.0 | .NET 9 |
Task.WhenEach |
13.0 | .NET 9 |
Extension members (extension blocks) |
14.0 | .NET 10 |
field keyword in properties |
14.0 | .NET 10 |
Null-conditional assignment ?.= |
14.0 | .NET 10 |
Unbound generics in nameof |
14.0 | .NET 10 |
Summary — Modern C# Checklist
| Feature | Use For |
|---|---|
| Pattern matching | Replace if/switch chains with expressive matching |
| Records | DTOs, events, value objects — value equality + immutability |
required |
Enforce property initialization at compile time |
| Collection expressions | [1, 2, 3] — concise collection creation |
| Primary constructors | DI injection in services — less boilerplate |
file types |
Hide implementation details within a file |
global using |
Reduce repetitive using directives |
| Raw strings | JSON, SQL, multi-line text without escaping |
params spans |
Variable arguments without array allocation |
| List patterns | Destructure arrays/lists in switch expressions |