You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

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 TypePurposeExample
Type PatternTest and cast in one stepobj is string s
Constant PatternMatch specific valuesx is null
Relational PatternCompare with operatorsage is >= 18
Logical PatternCombine patterns with and/or/notx is > 0 and < 100
Property PatternMatch object properties{ Length: > 0 }
Positional PatternDeconstruct 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:

SyntaxMeaningExample
stringNon-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 EqualityCompares by content, not reference
ToString OverrideReadable string representation
DeconstructionExtract properties to variables
with ExpressionNon-destructive mutation
init PropertiesSet 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 with expressions
  • 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

FeatureSyntaxPrimary Benefit
Pattern Matchingobj is Type tType-safe extraction
Switch Expressionsx switch { ... }Concise conditionals
Nullable Referencestring?Null safety
Recordsrecord Person(...)Immutable data
with Expressionperson with { Age = 30 }Non-destructive mutation
Init Properties{ get; init; }Initialization-only
Required Membersrequired string NameEnforced initialization
Target-Typed newList x = new();Less repetition
Raw Strings"""text"""No escaping
List Patterns[1, 2, ..]Array matching
Global Usingsglobal using System;Project-wide imports
File-Scoped NSnamespace App;Less indentation

Remember:

  1. Pattern matching is your friendβ€”use it for cleaner conditionals
  2. Records are perfect for DTOs, API models, and immutable data
  3. Nullable reference types prevent null exceptions but require discipline
  4. Init properties provide immutability without constructor bloat
  5. 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
  • with expressions copy all propertiesβ€”consider for large objects
  • Switch expressions are as fast as if-else chains

πŸ“š Further Study

  1. Official Microsoft Docs - What's New in C#: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/
  2. Pattern Matching Deep Dive: https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching
  3. 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! πŸš€