Modern Language Features
Leverage pattern matching, records, and contemporary C# features for expressive code
Modern Language Features in C#
Master modern C# syntax with free flashcards and practical coding examples. This lesson covers pattern matching, nullable reference types, records, top-level statements, and advanced expression featuresβessential skills for writing clean, maintainable C# code in contemporary applications.
Welcome to Modern C# π»
C# has evolved dramatically since its initial release, with each version introducing powerful features that make code more concise, expressive, and type-safe. The modern features we'll explore today represent the culmination of years of language evolution, incorporating concepts from functional programming while maintaining C#'s object-oriented foundation.
Why Modern Features Matter:
- Reduced boilerplate: Write less code to express the same intent
- Enhanced safety: Catch more errors at compile-time rather than runtime
- Improved readability: Express complex logic in clearer, more maintainable ways
- Better performance: Many modern features enable compiler optimizations
π‘ Tip: These features aren't just syntactic sugarβthey fundamentally change how you approach problem-solving in C#.
Core Concepts
1. Pattern Matching π―
Pattern matching allows you to test values against patterns and extract information from them in a single, elegant expression. It's evolved from simple type checks to a sophisticated feature supporting multiple pattern types.
Types of Patterns:
| Pattern Type | Purpose | Example |
|---|---|---|
| Type Pattern | Test and cast in one step | obj is string s |
| Constant Pattern | Match specific values | x is null |
| Relational Pattern | Compare with operators | age is >= 18 |
| Logical Pattern | Combine patterns with and/or/not | x is > 0 and < 100 |
| Property Pattern | Match object properties | { Length: > 0 } |
| Positional Pattern | Deconstruct tuples/records | (var x, var y) |
Switch Expressions are a powerful pattern matching tool that returns values:
string GetSeasonEmoji(int month) => month switch
{
12 or 1 or 2 => "βοΈ",
>= 3 and <= 5 => "πΈ",
>= 6 and <= 8 => "βοΈ",
>= 9 and <= 11 => "π",
_ => throw new ArgumentException("Invalid month")
};
π‘ Tip: Use the discard pattern _ as your default caseβit's more idiomatic than default.
Property Patterns let you match on object structure:
decimal CalculateDiscount(Customer customer) => customer switch
{
{ IsVIP: true, OrderCount: > 100 } => 0.30m,
{ IsVIP: true } => 0.20m,
{ OrderCount: > 50 } => 0.15m,
{ OrderCount: > 10 } => 0.10m,
_ => 0m
};
List Patterns (C# 11+) match array/list elements:
string AnalyzeArray(int[] numbers) => numbers switch
{
[] => "Empty",
[var single] => $"One element: {single}",
[var first, .., var last] => $"First: {first}, Last: {last}",
[1, 2, ..] => "Starts with 1, 2",
_ => "Other pattern"
};
π§ Memory Device: Think of patterns as "shape detectors"βthey check if data fits a specific "shape" and extract pieces simultaneously.
2. Nullable Reference Types β οΈ
Nullable reference types (NRT) fundamentally change C#'s null handling by making nullability explicit in the type system. This prevents the "billion-dollar mistake" of unexpected null reference exceptions.
Enabling NRT:
// In .csproj file
<Nullable>enable</Nullable>
// Or per-file with directives
#nullable enable
Type Annotations:
| Syntax | Meaning | Example |
|---|---|---|
string | Non-nullable (must have value) | string name = "Alice"; |
string? | Nullable (can be null) | string? middleName = null; |
string! | Null-forgiving (suppress warning) | return obj!.ToString(); |
Null Safety Patterns:
// Null-conditional operator
int? length = text?.Length;
// Null-coalescing operator
string display = name ?? "Unknown";
// Null-coalescing assignment
name ??= "Default";
// Pattern matching with null check
if (obj is not null)
{
// Compiler knows obj is non-null here
Console.WriteLine(obj.ToString());
}
Working with Legacy Code:
// Suppress warnings when you know better than the compiler
public void ProcessUser(string userId)
{
var user = FindUserById(userId); // Might return null
// You verified elsewhere that user exists
Console.WriteLine(user!.Name); // ! suppresses warning
}
β οΈ Common Mistake: Over-using the null-forgiving operator ! defeats the purpose of NRT. Only use it when you have proof the value isn't null that the compiler can't infer.
π‘ Best Practice: Enable nullable reference types in new projects from day one. Migrating later is significantly more work.
3. Records π
Records are reference types designed for immutable data. They provide value-based equality, concise syntax, and built-in functionality for data-centric scenarios.
Record Declaration Styles:
// Positional record (C# 9+)
public record Person(string FirstName, string LastName, int Age);
// Traditional record
public record Employee
{
public string Name { get; init; }
public decimal Salary { get; init; }
public DateTime HireDate { get; init; }
}
// Record struct (C# 10+) - value type
public record struct Point(double X, double Y);
Key Record Features:
π What You Get Free with Records
| Value Equality | Compares by content, not reference |
| ToString Override | Readable string representation |
| Deconstruction | Extract properties to variables |
| with Expression | Non-destructive mutation |
| init Properties | Set during initialization only |
Value Equality Example:
var person1 = new Person("Alice", "Smith", 30);
var person2 = new Person("Alice", "Smith", 30);
Console.WriteLine(person1 == person2); // True - content matches!
// Compare to classes:
public class PersonClass
{
public string Name { get; set; }
}
var p1 = new PersonClass { Name = "Alice" };
var p2 = new PersonClass { Name = "Alice" };
Console.WriteLine(p1 == p2); // False - different references
Non-Destructive Mutation with with:
var alice = new Person("Alice", "Smith", 30);
var olderAlice = alice with { Age = 31 };
Console.WriteLine(alice.Age); // 30 - original unchanged
Console.WriteLine(olderAlice.Age); // 31 - new instance
Inheritance with Records:
public record Vehicle(string Make, string Model);
public record Car(string Make, string Model, int Doors)
: Vehicle(Make, Model);
var car = new Car("Toyota", "Camry", 4);
π§ Try This: Create a record for your domain model and use with expressions instead of property setters. Notice how it eliminates mutation bugs.
4. Init-Only Properties π
Init-only setters allow property assignment during object initialization but prevent modification afterward, enabling immutability without constructor ceremony.
public class Configuration
{
public string ApiUrl { get; init; }
public int Timeout { get; init; }
public bool EnableLogging { get; init; }
}
// Can set during initialization
var config = new Configuration
{
ApiUrl = "https://api.example.com",
Timeout = 30,
EnableLogging = true
};
// Cannot modify after initialization
// config.Timeout = 60; // Compiler error!
Required Properties (C# 11+) enforce initialization:
public class User
{
public required string Username { get; init; }
public required string Email { get; init; }
public string? PhoneNumber { get; init; } // Optional
}
// Must initialize required properties
var user = new User
{
Username = "alice",
Email = "alice@example.com"
// PhoneNumber is optional
};
IMMUTABILITY SPECTRUM IN C#
βββββββββββββββββββββββββββββββββββββββββββ
β Fully Mutable β
β β β
β public string Name { get; set; } β
β β
β Init-Only (set during creation) β
β β β
β public string Name { get; init; } β
β β
β Read-Only (set in constructor) β
β β β
β public string Name { get; } β
β β
β Fully Immutable (literal) β
β β β
β public const string Name = "Fixed"; β
βββββββββββββββββββββββββββββββββββββββββββ
5. Top-Level Statements π
Top-level statements eliminate boilerplate for simple programs, letting you write executable code directly without class/method wrappers.
Before (Traditional):
using System;
namespace MyApp
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
}
After (Top-Level Statements):
using System;
Console.WriteLine("Hello, World!");
What You Can Do:
// Variables
var name = "Alice";
int age = 30;
// Function calls
Console.WriteLine($"Name: {name}, Age: {age}");
// Control flow
if (age >= 18)
{
Console.WriteLine("Adult");
}
// Loops
for (int i = 0; i < 5; i++)
{
Console.WriteLine(i);
}
// Local functions
int Add(int a, int b) => a + b;
// Await (implicitly async)
await Task.Delay(1000);
Console.WriteLine("Done");
// Access args
if (args.Length > 0)
{
Console.WriteLine($"First arg: {args[0]}");
}
// Return exit code
return 0;
β οΈ Limitation: Only one file in your project can use top-level statements. Perfect for simple programs, scripts, or program entry points.
6. Target-Typed New Expressions π―
Target-typed new lets you omit the type when it's obvious from context, reducing redundancy.
// Before
List<string> names = new List<string>();
Dictionary<int, string> map = new Dictionary<int, string>();
// After - type inferred from left side
List<string> names = new();
Dictionary<int, string> map = new();
// Works with properties
public class Config
{
public List<string> AllowedHosts { get; set; } = new();
}
// Works with method returns
public List<int> GetNumbers()
{
return new() { 1, 2, 3 }; // Returns List<int>
}
7. Enhanced String Features π€
Raw String Literals (C# 11+) simplify multi-line strings and eliminate escape sequences:
// Traditional - escaping nightmare
string json = "{\"name\":\"Alice\",\"age\":30}";
// Verbatim string - but weird with quotes
string json2 = @"{""name"":""Alice"",""age"":30}";
// Raw string literal - clean!
string json3 = """
{
"name": "Alice",
"age": 30
}
""";
String Interpolation Improvements:
// Newlines in interpolation (C# 11+)
int x = 5, y = 10;
string result = $"Sum: {
x + y
}";
// Format specifiers
double price = 29.99;
Console.WriteLine($"Price: {price:C}"); // Currency format
Console.WriteLine($"Hex: {255:X}"); // Hexadecimal
UTF-8 String Literals (C# 11+):
// Creates ReadOnlySpan<byte> directly
ReadOnlySpan<byte> utf8 = "Hello"u8;
8. Global Usings π
Global usings apply to all files in your project, eliminating repetitive using statements.
// In GlobalUsings.cs or any file
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;
// Now all files have these usings automatically!
Implicit Usings in .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
This automatically includes common namespaces based on your SDK (web, console, etc.).
9. File-Scoped Namespaces π
File-scoped namespaces reduce indentation by declaring the namespace once for the entire file:
// Traditional
namespace MyCompany.MyApp.Services
{
public class UserService
{
public void DoSomething()
{
// Code at 3 levels of indentation
}
}
}
// File-scoped (C# 10+)
namespace MyCompany.MyApp.Services;
public class UserService
{
public void DoSomething()
{
// Code at 2 levels of indentation
}
}
β οΈ Rule: Only one namespace declaration allowed per file when using file-scoped syntax.
Practical Examples
Example 1: Building a Type-Safe API Response Handler π
Let's combine multiple modern features to create a robust API response handler:
using System.Net.Http.Json;
// Records for data modeling
public record ApiResponse<T>(
bool Success,
T? Data,
string? ErrorMessage,
int StatusCode
);
public record User(int Id, string Name, string Email);
// Pattern matching for response handling
public static class ResponseHandler
{
public static string ProcessResponse(ApiResponse<User> response) => response switch
{
{ Success: true, Data: not null } =>
$"β
User loaded: {response.Data.Name}",
{ StatusCode: 404 } =>
"β οΈ User not found",
{ StatusCode: >= 500 } =>
"β Server error - please try again later",
{ ErrorMessage: not null } =>
$"β Error: {response.ErrorMessage}",
_ => "β Unknown error occurred"
};
}
// Usage
var successResponse = new ApiResponse<User>(
Success: true,
Data: new User(1, "Alice", "alice@example.com"),
ErrorMessage: null,
StatusCode: 200
);
Console.WriteLine(ResponseHandler.ProcessResponse(successResponse));
// Output: β
User loaded: Alice
Why This Works:
- Records provide immutable, value-based data structures
- Nullable types make null handling explicit
- Pattern matching creates clear, exhaustive case handling
- Target-typed new reduces syntax noise
Example 2: Immutable Configuration System βοΈ
Create a type-safe configuration system using init properties and required members:
public class DatabaseConfig
{
public required string ConnectionString { get; init; }
public required int MaxConnections { get; init; }
public int TimeoutSeconds { get; init; } = 30;
public bool EnableRetry { get; init; } = true;
}
public class AppSettings
{
public required DatabaseConfig Database { get; init; }
public required string ApiKey { get; init; }
public LogLevel LogLevel { get; init; } = LogLevel.Information;
}
public enum LogLevel { Debug, Information, Warning, Error }
// Usage
var settings = new AppSettings
{
Database = new DatabaseConfig
{
ConnectionString = "Server=localhost;Database=mydb",
MaxConnections = 100,
TimeoutSeconds = 60
},
ApiKey = "secret-key-123",
LogLevel = LogLevel.Debug
};
// Create modified version without mutation
var prodSettings = settings with
{
LogLevel = LogLevel.Warning,
Database = settings.Database with
{
MaxConnections = 200
}
};
Console.WriteLine(settings.Database.MaxConnections); // 100
Console.WriteLine(prodSettings.Database.MaxConnections); // 200
Benefits:
- Required properties prevent incomplete configuration
- Init-only setters prevent accidental modification
- with expressions enable safe configuration derivation
- Default values provide sensible fallbacks
Example 3: Smart Collection Processing π
Use list patterns and switch expressions for elegant collection handling:
public static class DataAnalyzer
{
public static string AnalyzeNumbers(int[] numbers) => numbers switch
{
[] => "No data to analyze",
[var single] => $"Single value: {single}",
[var first, var second] when first == second =>
$"Two identical values: {first}",
[var x, var y] => $"Two values: {x} and {y}",
[var first, .., var last] when numbers.Length > 10 =>
$"Large dataset ({numbers.Length} items): {first} to {last}",
[var first, .. var middle, var last] =>
$"Small dataset: first={first}, middle={middle.Length}, last={last}",
_ => "Unexpected pattern"
};
public static string ClassifySequence(int[] seq) => seq switch
{
[1, 2, 3, ..] => "Starts with 1, 2, 3",
[.., 7, 8, 9] => "Ends with 7, 8, 9",
[.. var all] when all.All(x => x % 2 == 0) => "All even numbers",
[.. var all] when all.SequenceEqual(all.OrderBy(x => x)) => "Sorted ascending",
_ => "No special pattern"
};
}
// Usage examples
Console.WriteLine(DataAnalyzer.AnalyzeNumbers(new[] { 5 }));
// Output: Single value: 5
Console.WriteLine(DataAnalyzer.AnalyzeNumbers(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }));
// Output: Large dataset (12 items): 1 to 12
Console.WriteLine(DataAnalyzer.ClassifySequence(new[] { 2, 4, 6, 8 }));
// Output: All even numbers
Key Techniques:
- List patterns match specific array shapes
- Slice patterns (
..) capture ranges - Guards (
when) add conditional logic - LINQ integration for advanced checks
Example 4: Fluent Builder with Modern Syntax ποΈ
Create a fluent API using records and with expressions:
public record QueryBuilder(
string? Table = null,
List<string>? Columns = null,
string? WhereClause = null,
int? Limit = null
)
{
public QueryBuilder Select(params string[] columns) =>
this with { Columns = columns.ToList() };
public QueryBuilder From(string table) =>
this with { Table = table };
public QueryBuilder Where(string condition) =>
this with { WhereClause = condition };
public QueryBuilder Take(int limit) =>
this with { Limit = limit };
public string Build() => this switch
{
{ Table: null } => throw new InvalidOperationException("Table not specified"),
{ Columns: null or [] } => throw new InvalidOperationException("No columns selected"),
var q => $"""SELECT {string.Join(", ", q.Columns)}
FROM {q.Table}
{(q.WhereClause != null ? $"WHERE {q.WhereClause}" : "")}
{(q.Limit.HasValue ? $"LIMIT {q.Limit}" : "")}"""
};
}
// Usage - each step returns new immutable instance
var query = new QueryBuilder()
.Select("id", "name", "email")
.From("users")
.Where("age > 18")
.Take(10)
.Build();
Console.WriteLine(query);
/* Output:
SELECT id, name, email
FROM users
WHERE age > 18
LIMIT 10
*/
Design Highlights:
- Records provide value semantics and
withexpressions - Nullable properties with defaults enable optional configuration
- Fluent interface through immutable transformations
- Pattern matching validates state before building
- Raw string literals format output cleanly
Common Mistakes to Avoid β οΈ
1. Confusing == and is with Patterns
β Wrong:
if (obj == null) // Old style, but works
return;
β Better:
if (obj is null) // Modern pattern matching
return;
β Best for non-null:
if (obj is not null) // Clearer intent
ProcessObject(obj);
Why? Pattern matching is more consistent with other pattern syntax and reads more naturally.
2. Over-Using Null-Forgiving Operator
β Wrong:
public void ProcessUser(string? userId)
{
var user = FindUser(userId!)!; // Double danger!
Console.WriteLine(user!.Name!);
}
β Right:
public void ProcessUser(string? userId)
{
if (userId is null)
throw new ArgumentNullException(nameof(userId));
var user = FindUser(userId);
if (user is null)
throw new InvalidOperationException($"User {userId} not found");
Console.WriteLine(user.Name);
}
Rule of Thumb: If you're using ! more than once in a method, you're probably doing it wrong.
3. Forgetting Records Are Reference Types
β Wrong Assumption:
var person = new Person("Alice", 30);
var list = new List<Person> { person };
person = person with { Age = 31 }; // Creates NEW instance
Console.WriteLine(list[0].Age); // Still 30! Different object
β Understanding:
var person = new Person("Alice", 30);
var list = new List<Person> { person };
list[0] = list[0] with { Age = 31 }; // Update list reference
Console.WriteLine(list[0].Age); // Now 31
Key Point: with always creates a new instanceβit never mutates the original.
4. Incomplete Pattern Matching
β Wrong:
string GetStatus(int code) => code switch
{
200 => "OK",
404 => "Not Found",
500 => "Error"
// Missing default case - compiler warning!
};
β Right:
string GetStatus(int code) => code switch
{
200 => "OK",
404 => "Not Found",
500 => "Error",
_ => "Unknown Status"
};
Best Practice: Always include a discard pattern _ unless you've exhaustively matched all possibilities (like enum values).
5. Mixing Top-Level Statements with Classes
β Wrong:
// Program.cs
Console.WriteLine("Hello");
class MyClass { } // Can't define types after top-level statements!
β Right:
// Program.cs
using MyApp;
Console.WriteLine("Hello");
MyClass.DoWork();
// Types go in namespace at end, or in separate files
namespace MyApp;
public static class MyClass
{
public static void DoWork() { }
}
6. Misunderstanding init vs readonly
β Wrong:
public class Config
{
public string ApiKey { get; init; } // Can be null!
}
var config = new Config(); // ApiKey is null
β Right:
public class Config
{
public required string ApiKey { get; init; } // Must be set
}
var config = new Config { ApiKey = "key123" }; // Compiler enforces
Remember: init doesn't mean "must initialize"βuse required for that.
Key Takeaways π―
π Modern C# Quick Reference
| Feature | Syntax | Primary Benefit |
|---|---|---|
| Pattern Matching | obj is Type t | Type-safe extraction |
| Switch Expressions | x switch { ... } | Concise conditionals |
| Nullable Reference | string? | Null safety |
| Records | record Person(...) | Immutable data |
| with Expression | person with { Age = 30 } | Non-destructive mutation |
| Init Properties | { get; init; } | Initialization-only |
| Required Members | required string Name | Enforced initialization |
| Target-Typed new | List | Less repetition |
| Raw Strings | """text""" | No escaping |
| List Patterns | [1, 2, ..] | Array matching |
| Global Usings | global using System; | Project-wide imports |
| File-Scoped NS | namespace App; | Less indentation |
Remember:
- Pattern matching is your friendβuse it for cleaner conditionals
- Records are perfect for DTOs, API models, and immutable data
- Nullable reference types prevent null exceptions but require discipline
- Init properties provide immutability without constructor bloat
- Modern syntax isn't just shorterβit's often safer and more maintainable
When to Use What:
- Use records for data transfer objects and value objects
- Use pattern matching when you have multiple conditional branches
- Use nullable reference types in all new projects
- Use init properties when you want object initializer syntax with immutability
- Use top-level statements for simple scripts and minimal APIs
Performance Notes:
- Records have minimal overhead compared to classes
- Pattern matching compiles to efficient IL code
withexpressions copy all propertiesβconsider for large objects- Switch expressions are as fast as if-else chains
π Further Study
- Official Microsoft Docs - What's New in C#: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/
- Pattern Matching Deep Dive: https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching
- Nullable Reference Types Guide: https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
π‘ Pro Tip: Enable nullable reference types and file-scoped namespaces in your EditorConfig file to enforce modern practices across your entire codebase:
[*.cs]
csharp_prefer_simple_using_statement = true
csharp_style_namespace_declarations = file_scoped
csharp_style_prefer_null_check_over_type_check = true
csharp_style_prefer_pattern_matching = true
Mastering these modern C# features will make your code more maintainable, safer, and more expressive. Start incorporating them one at a time into your projectsβyour future self will thank you! π