Modern C# Features: Pattern Matching, Records, and Spans
February 7, 2025 · 8 min read
C#, .NET, Performance, Interview Prep
Introduction
Modern C# has evolved dramatically. Features from C# 8 through 12 have changed how we write code - making it more expressive, safer, and faster. These features come up frequently in interviews for senior positions.
Pattern Matching — Write Less, Express More
TL;DR
Pattern matching turns verbose if/switch statements into concise, expressive code. Learn the patterns from C# 8-12.
The Real Explanation
// Type patterns
object obj = GetSomething();
if (obj is string s && s.Length > 0)
{
Console.WriteLine($"Non-empty string: {s}");
}
// Property patterns
if (user is { IsActive: true, Role: "Admin" })
{
// User is active admin
}
// Switch expressions (so much cleaner)
var discount = customer switch
{
{ IsPremium: true, YearsActive: > 5 } => 0.25m,
{ IsPremium: true } => 0.15m,
{ YearsActive: > 10 } => 0.10m,
_ => 0m
};
// List patterns (C# 11)
int[] numbers = { 1, 2, 3, 4, 5 };
if (numbers is [1, 2, .. var rest, 5])
{
// rest is [3, 4]
}
// Relational patterns
var grade = score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
_ => "F"
};
// Combining patterns with and/or/not
if (obj is not null and string { Length: > 0 })
{
// Not null, is a string, has content
}
I refactored a 200-line switch statement into a 30-line switch expression using property patterns. The code review comment was "I didn't know C# could do this" - and that was from a 15-year C# veteran.
The Gotcha
// Order matters in switch expressions!
var result = value switch
{
> 0 => "positive",
0 => "zero",
< 0 => "negative"
};
// If you put _ first, it catches everything
var broken = value switch
{
_ => "catch all", // This matches everything!
> 0 => "positive", // Never reached - compiler warning
};
// The when clause gotcha
var status = order switch
{
{ Total: > 1000 } when DateTime.Now.DayOfWeek == DayOfWeek.Friday => "VIP Friday",
{ Total: > 1000 } => "VIP",
_ => "Regular"
};
// The 'when' is evaluated at runtime, not compile time
Interview Tip
Show you can refactor verbose code into pattern matching. The switch expression with property patterns is particularly impressive - it shows modern C# fluency.
Records — Immutable by Design
TL;DR
Records are reference types optimized for immutability. Use them for DTOs, value objects, and anywhere you want value-based equality.
The Real Explanation
// Positional record (concise)
public record Person(string FirstName, string LastName);
// Equivalent to writing ~50 lines:
// - Init-only properties
// - Constructor
// - Equals, GetHashCode, ToString
// - Deconstruct
// Value-based equality
var p1 = new Person("John", "Doe");
var p2 = new Person("John", "Doe");
Console.WriteLine(p1 == p2); // True! (classes would be false)
// Non-destructive mutation with 'with'
var p3 = p1 with { LastName = "Smith" };
// p1 is unchanged, p3 is new instance
// Record struct (C# 10) - value type record
public record struct Point(int X, int Y);
Records have become my default for DTOs. When I'm defining API request/response types, they're almost always records now:
public record CreateOrderRequest(
int CustomerId,
List<OrderItemRequest> Items,
string? Notes = null
);
public record OrderItemRequest(int ProductId, int Quantity);
public record OrderResponse(
int OrderId,
DateTime CreatedAt,
decimal Total
);
The Gotcha
// Records with mutable properties break everything
public record BadRecord
{
public List<string> Items { get; set; } = new();
}
var r1 = new BadRecord();
var r2 = r1; // Same reference to Items list
r2.Items.Add("oops"); // r1.Items also has "oops"
// Even with 'with', the list is shallow copied
var r3 = r1 with { }; // r3.Items points to SAME list
// Fix: Use immutable collections
public record GoodRecord
{
public ImmutableList<string> Items { get; init; } = ImmutableList<string>.Empty;
}
Inheritance gotcha:
public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
var animal = new Animal("Buddy");
var dog = new Dog("Buddy", "Lab");
// Different types, so not equal even with same Name
Console.WriteLine(animal == dog); // False
// But be careful with covariance
Animal a = new Dog("Buddy", "Lab");
Animal b = new Dog("Buddy", "Lab");
Console.WriteLine(a == b); // True! Runtime type comparison
Interview Tip
Know when to use record vs class vs struct. Records are great for DTOs crossing boundaries, value objects in DDD, and configuration objects.
Span and Memory — Zero-Allocation String Parsing
TL;DR
Span<T> is a stack-only view over contiguous memory. It enables parsing and slicing without allocating new arrays or strings.
The Real Explanation
// Traditional string parsing - allocates on every split
string csv = "John,Doe,30,Engineer";
string[] parts = csv.Split(','); // Allocates array + 4 strings
// Span-based parsing - zero allocations
ReadOnlySpan<char> span = csv.AsSpan();
var firstName = span[..4]; // "John" - no allocation
var lastName = span[5..8]; // "Doe" - no allocation
// Parsing numbers without string allocation
ReadOnlySpan<char> ageSpan = span[9..11];
int age = int.Parse(ageSpan); // Parses directly from span
// Real-world: Parsing a log line
public static (DateTime timestamp, ReadOnlySpan<char> level, ReadOnlySpan<char> message)
ParseLogLine(ReadOnlySpan<char> line)
{
// Format: "2024-01-15T10:30:00 [INFO] User logged in"
var timestamp = DateTime.Parse(line[..19]);
var level = line[21..25]; // "INFO"
var message = line[27..]; // "User logged in"
return (timestamp, level, message);
}
I used Spans when building a high-throughput log ingestion service. We went from allocating gigabytes per minute to almost nothing. GC pauses dropped from 200ms to under 5ms.
The Gotcha
// Span CANNOT be stored in fields (stack-only)
public class BadClass
{
private Span<int> _span; // Compiler error!
}
// Use Memory<T> for heap storage
public class GoodClass
{
private Memory<int> _memory; // This works
public void Process()
{
Span<int> span = _memory.Span; // Get span when needed
}
}
// Async methods can't use Span (stack unwinding)
public async Task ProcessAsync(Span<int> data) // Error!
{
}
// Fix: Use Memory<T> for async
public async Task ProcessAsync(Memory<int> data)
{
await Task.Delay(100);
Span<int> span = data.Span; // Get span after await
}
ArrayPool for reusable buffers:
// Rent from pool instead of allocating
var buffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
Span<byte> span = buffer.AsSpan(0, actualLength);
// Use span...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // Return to pool
}
Interview Tip
This is an advanced topic that shows you care about performance. Mention use cases: high-throughput log parsing, JSON processing (System.Text.Json uses spans internally), network protocol parsing.
Other Modern Features Worth Knowing
Primary Constructors (C# 12)
// Old way
public class UserService
{
private readonly IUserRepository _repo;
private readonly ILogger<UserService> _logger;
public UserService(IUserRepository repo, ILogger<UserService> logger)
{
_repo = repo;
_logger = logger;
}
}
// New way - primary constructor
public class UserService(IUserRepository repo, ILogger<UserService> logger)
{
public async Task<User?> GetUserAsync(int id)
{
logger.LogInformation("Getting user {Id}", id);
return await repo.GetByIdAsync(id);
}
}
Collection Expressions (C# 12)
// Old ways
int[] a = new int[] { 1, 2, 3 };
List<int> b = new List<int> { 1, 2, 3 };
// New way
int[] a = [1, 2, 3];
List<int> b = [1, 2, 3];
// Spread operator
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] combined = [..first, ..second]; // [1, 2, 3, 4, 5, 6]
Required Members (C# 11)
public class User
{
public required string Email { get; init; }
public required string Name { get; init; }
public string? Phone { get; init; }
}
// Compiler error if required members not set
var user = new User(); // Error: Email and Name required
var user = new User { Email = "test@test.com", Name = "Test" }; // OK
Summary
| Feature | Use Case | Key Benefit |
|---|---|---|
| Pattern Matching | Complex conditionals | Expressive, less code |
| Records | DTOs, value objects | Immutability + value equality |
| Span/Memory | High-perf parsing | Zero allocations |
| Primary Constructors | DI-heavy classes | Less boilerplate |
| Collection Expressions | Array/list init | Cleaner syntax |
Next up: Entity Framework Core Deep Dive - performance tuning and migration strategies.
Part 3 of the C# Interview Prep series.