C# 13-14
Explore params collections, extension types, and future directions
C# 13-14: Latest Language Evolution
Master the cutting-edge features of C# 13 and 14 with free flashcards and spaced repetition practice. This lesson covers primary constructors, collection expressions, interceptors, extension types, and discriminated unionsβessential concepts for modern C# development. As C# continues to evolve rapidly, staying current with the latest language features helps you write more expressive, maintainable, and performant code.
Welcome to Modern C# π»
Welcome to the forefront of C# language evolution! C# 13 and 14 represent Microsoft's commitment to making the language more concise, expressive, and powerful. These versions introduce features that have been requested by the community for years, drawing inspiration from functional programming paradigms while maintaining C#'s object-oriented foundations.
What makes C# 13-14 special?
- Collection Expressions: Unified syntax for creating collections
- Primary Constructors: Simplified class declarations
- Interceptors: Advanced metaprogramming capabilities
- Extension Types: Adding members to existing types
- Discriminated Unions: Type-safe pattern matching
These features build upon the solid foundation of earlier C# versions while pushing the language forward into new territory. Whether you're building web APIs, desktop applications, or game engines, these modern features will enhance your development experience.
Core Concepts π
1. Collection Expressions (C# 12+, Enhanced in 13-14)
Collection expressions provide a unified, concise syntax for creating and initializing collections. Instead of using different syntax for arrays, lists, and spans, you can now use square brackets [] consistently.
Traditional approach:
int[] array = new int[] { 1, 2, 3 };
List<int> list = new List<int> { 1, 2, 3 };
Span<int> span = stackalloc int[] { 1, 2, 3 };
Modern collection expressions:
int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
ImmutableArray<int> immutable = [1, 2, 3];
π‘ Key advantages:
- Type inference: The compiler determines the target type from context
- Spread operator: Use
..to expand collections inline - Consistency: Same syntax works for arrays, lists, spans, and more
Spread operator in action:
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] combined = [..first, ..second]; // [1, 2, 3, 4, 5, 6]
int[] extended = [0, ..first, 99]; // [0, 1, 2, 3, 99]
| Collection Type | Old Syntax | New Syntax |
|---|---|---|
| Array | new int[] {1,2,3} | [1,2,3] |
| List | new List<int> {1,2,3} | [1,2,3] |
| Span | stackalloc int[] {1,2,3} | [1,2,3] |
| ImmutableArray | ImmutableArray.Create(1,2,3) | [1,2,3] |
2. Primary Constructors (C# 12+)
Primary constructors allow you to declare constructor parameters directly in the class declaration, eliminating boilerplate code for simple classes.
Traditional approach:
public class Person
{
private readonly string _name;
private readonly int _age;
public Person(string name, int age)
{
_name = name;
_age = age;
}
public void Introduce() => Console.WriteLine($"I'm {_name}, age {_age}");
}
With primary constructors:
public class Person(string name, int age)
{
public void Introduce() => Console.WriteLine($"I'm {name}, age {age}");
}
π‘ Important notes:
- Parameters are captured and available throughout the class
- They're not automatically properties (unless you declare them as such)
- Can be combined with regular properties and fields
- Works with classes, structs, and records
Combining with properties:
public class Product(string name, decimal price)
{
public string Name { get; } = name.ToUpper(); // Transform parameter
public decimal Price { get; } = price < 0 ? 0 : price; // Validate
public decimal Tax => Price * 0.1m; // Computed property
}
3. Interceptors (Experimental in C# 12-13)
Interceptors are an advanced metaprogramming feature that allows you to replace method calls at compile time. This is particularly useful for source generators to optimize or modify code behavior.
β οΈ Warning: Interceptors are experimental and require enabling the feature flag. They're primarily intended for library authors and source generators.
How interceptors work:
// Original code
public class Calculator
{
public static int Add(int a, int b) => a + b;
}
// Usage
int result = Calculator.Add(5, 10);
Interceptor definition:
using System.Runtime.CompilerServices;
namespace MyInterceptors;
public static class CalculatorInterceptors
{
[InterceptsLocation("Program.cs", line: 15, column: 25)]
public static int InterceptAdd(int a, int b)
{
Console.WriteLine($"Intercepted: {a} + {b}");
return a + b;
}
}
Use cases for interceptors:
- Performance optimization: Replace slow methods with optimized versions
- Logging and diagnostics: Add instrumentation without modifying source
- API versioning: Redirect old API calls to new implementations
- Source generators: Enable compile-time code transformation
INTERCEPTOR FLOW
π Original Code
β
β
π Compiler Analysis
β
β
π― Interceptor Match
β
ββββββ΄βββββ
β β
β
Replace β No Match
β β
β β
New Method Original Method
4. Extension Types (Proposed for C# 13-14)
Extension types (also called "extensions" or "roles") go beyond extension methods by allowing you to add not just methods, but also properties, operators, and even implement interfaces for existing types.
β οΈ Note: This feature is still in proposal stage and syntax may change.
Conceptual syntax:
// Extending an existing type
extension StringExtensions for string
{
public bool IsNullOrEmpty => string.IsNullOrEmpty(this);
public int WordCount => this.Split(' ').Length;
public string Truncate(int maxLength)
{
if (this.Length <= maxLength) return this;
return this.Substring(0, maxLength) + "...";
}
}
// Usage
string text = "Hello world";
if (!text.IsNullOrEmpty)
{
Console.WriteLine(text.WordCount); // 2
Console.WriteLine(text.Truncate(5)); // "Hello..."
}
Advantages over extension methods:
- Can add properties, not just methods
- Can implement interfaces on existing types
- Better discoverability in IntelliSense
- Cleaner syntax for complex extensions
5. Discriminated Unions (Proposed for C# 13-14)
Discriminated unions (also called "sum types" or "tagged unions") allow a type to be one of several defined variants. This is a powerful feature from functional programming that enables exhaustive pattern matching.
Conceptual syntax:
// Define a union type
public union Result<T>
{
Success(T value),
Error(string message),
NotFound
}
// Pattern matching
public string ProcessResult(Result<int> result)
{
return result switch
{
Success(var value) => $"Got value: {value}",
Error(var msg) => $"Error: {msg}",
NotFound => "Item not found",
_ => throw new InvalidOperationException()
};
}
Current workaround using records:
public abstract record Result<T>;
public record Success<T>(T Value) : Result<T>;
public record Error<T>(string Message) : Result<T>;
public record NotFound<T> : Result<T>;
// Usage
public Result<int> Divide(int a, int b)
{
if (b == 0) return new Error<int>("Division by zero");
return new Success<int>(a / b);
}
public void Process()
{
var result = Divide(10, 2);
string message = result switch
{
Success<int>(var value) => $"Result: {value}",
Error<int>(var msg) => $"Error: {msg}",
NotFound<int> => "Not found",
_ => "Unknown"
};
}
π‘ Mnemonic for discriminated unions: Think of them as a "menu of options" where you must choose exactly one item, and the compiler ensures you handle all possibilities.
Detailed Examples π―
Example 1: Building a Type-Safe API Response with Modern Features
Let's build a complete API response system using C# 13-14 features:
using System.Collections.Immutable;
// Using primary constructors for clean data classes
public class ApiResponse<T>(int statusCode, T? data, string[] errors)
{
public int StatusCode { get; } = statusCode;
public T? Data { get; } = data;
public ImmutableArray<string> Errors { get; } = [..errors];
public bool IsSuccess => StatusCode >= 200 && StatusCode < 300;
// Factory methods using collection expressions
public static ApiResponse<T> Ok(T data) =>
new(200, data, []);
public static ApiResponse<T> Error(params string[] errors) =>
new(400, default, errors);
public static ApiResponse<T> NotFound(string message) =>
new(404, default, [message]);
}
// Usage example
public class UserService
{
private readonly Dictionary<int, User> _users = new()
{
[1] = new("Alice", "alice@example.com"),
[2] = new("Bob", "bob@example.com")
};
public ApiResponse<User> GetUser(int id)
{
if (_users.TryGetValue(id, out var user))
return ApiResponse<User>.Ok(user);
return ApiResponse<User>.NotFound($"User {id} not found");
}
public ApiResponse<User[]> GetAllUsers()
{
// Collection expression for array creation
User[] allUsers = [.._users.Values];
return ApiResponse<User[]>.Ok(allUsers);
}
}
public record User(string Name, string Email);
// Processing responses
void ProcessResponse()
{
var service = new UserService();
var response = service.GetUser(1);
string message = response switch
{
{ IsSuccess: true, Data: var user } => $"Found: {user!.Name}",
{ StatusCode: 404 } => "User not found",
{ Errors: var errors } when errors.Length > 0 =>
$"Errors: {string.Join(", ", errors)}",
_ => "Unknown response"
};
Console.WriteLine(message);
}
Key points:
- Primary constructors eliminate boilerplate in
ApiResponse - Collection expressions make array creation concise:
[..errors],[message] - Pattern matching provides type-safe response handling
ImmutableArrayensures thread safety
Example 2: Advanced Collection Manipulation
Collection expressions shine when manipulating and combining data:
public class DataProcessor
{
public int[] ProcessNumbers(int[] input)
{
// Split into parts
var small = input.Where(x => x < 10).ToArray();
var medium = input.Where(x => x >= 10 && x < 100).ToArray();
var large = input.Where(x => x >= 100).ToArray();
// Combine with separators using collection expressions
int[] result = [
-1, // Marker for small numbers
..small,
-2, // Marker for medium numbers
..medium,
-3, // Marker for large numbers
..large
];
return result;
}
public List<T> MergeWithDuplicates<T>(List<T> list1, List<T> list2)
{
// Create combined list with duplicates preserved
List<T> merged = [..list1, ..list2];
return merged;
}
public ImmutableArray<string> BuildPath(params string[] segments)
{
// Filter empty segments and combine
var validSegments = segments.Where(s => !string.IsNullOrEmpty(s));
ImmutableArray<string> path = ["root", ..validSegments];
return path;
}
}
// Usage
var processor = new DataProcessor();
int[] numbers = [5, 50, 500, 15, 150, 3, 30, 300];
int[] processed = processor.ProcessNumbers(numbers);
// Result: [-1, 5, 3, -2, 50, 15, 30, -3, 500, 150, 300]
var merged = processor.MergeWithDuplicates(
[1, 2, 3],
[3, 4, 5]
); // [1, 2, 3, 3, 4, 5]
var path = processor.BuildPath("users", "", "profile", "avatar");
// ImmutableArray: ["root", "users", "profile", "avatar"]
Benefits demonstrated:
- Spread operator (
..) enables flexible composition - Same syntax works across different collection types
- Mixing literals and spreads creates expressive code
- Type inference reduces verbosity
Example 3: Primary Constructors with Validation
Primary constructors work well with validation logic:
public class BankAccount(string accountNumber, decimal initialBalance)
{
private decimal _balance = initialBalance >= 0
? initialBalance
: throw new ArgumentException("Initial balance cannot be negative");
public string AccountNumber { get; } =
!string.IsNullOrWhiteSpace(accountNumber)
? accountNumber
: throw new ArgumentException("Account number required");
public decimal Balance => _balance;
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Deposit amount must be positive");
_balance += amount;
LogTransaction("Deposit", amount);
}
public bool Withdraw(decimal amount)
{
if (amount <= 0 || amount > _balance)
return false;
_balance -= amount;
LogTransaction("Withdrawal", amount);
return true;
}
private void LogTransaction(string type, decimal amount)
{
// Primary constructor parameters available throughout
Console.WriteLine($"[{accountNumber}] {type}: ${amount:F2}, Balance: ${_balance:F2}");
}
}
// Inheritance with primary constructors
public class SavingsAccount(string accountNumber, decimal initialBalance, decimal interestRate)
: BankAccount(accountNumber, initialBalance)
{
public decimal InterestRate { get; } = interestRate;
public void ApplyInterest()
{
var interest = Balance * InterestRate;
Deposit(interest);
Console.WriteLine($"Interest applied: ${interest:F2}");
}
}
// Usage
var account = new BankAccount("ACC-001", 1000m);
account.Deposit(500m); // [ACC-001] Deposit: $500.00, Balance: $1500.00
account.Withdraw(200m); // [ACC-001] Withdrawal: $200.00, Balance: $1300.00
var savings = new SavingsAccount("SAV-001", 5000m, 0.03m);
savings.ApplyInterest(); // Interest applied: $150.00
Pattern demonstrated:
- Constructor parameters used in validation expressions
- Parameters accessible in all methods
- Inheritance chains primary constructors properly
- Computed properties based on constructor parameters
Example 4: Simulating Discriminated Unions with Records
Until true discriminated unions arrive, we can simulate them effectively:
// Base union type
public abstract record PaymentResult;
// Union variants
public record PaymentSuccess(string TransactionId, decimal Amount) : PaymentResult;
public record PaymentFailed(string ErrorCode, string Message) : PaymentResult;
public record PaymentPending(string ConfirmationRequired) : PaymentResult;
// Service using the union
public class PaymentService
{
private readonly Random _random = new();
public PaymentResult ProcessPayment(decimal amount)
{
// Simulate different outcomes
int outcome = _random.Next(0, 3);
return outcome switch
{
0 => new PaymentSuccess(
TransactionId: Guid.NewGuid().ToString(),
Amount: amount
),
1 => new PaymentFailed(
ErrorCode: "INSUFFICIENT_FUNDS",
Message: "Account balance too low"
),
_ => new PaymentPending(
ConfirmationRequired: "2FA verification needed"
)
};
}
}
// Exhaustive pattern matching
public class PaymentHandler
{
public string HandlePayment(PaymentResult result)
{
// Compiler helps ensure all cases are handled
return result switch
{
PaymentSuccess(var txId, var amount) =>
$"β
Payment successful! Transaction: {txId}, Amount: ${amount:F2}",
PaymentFailed(var code, var msg) =>
$"β Payment failed [{code}]: {msg}",
PaymentPending(var confirmation) =>
$"β³ Payment pending: {confirmation}",
_ => throw new InvalidOperationException("Unknown payment result")
};
}
// Processing with collection expressions
public string[] ProcessBatch(PaymentResult[] results)
{
List<string> successMessages = [];
List<string> errorMessages = [];
List<string> pendingMessages = [];
foreach (var result in results)
{
switch (result)
{
case PaymentSuccess(var txId, var amount):
successMessages.Add($"Success: {txId}");
break;
case PaymentFailed(var code, var msg):
errorMessages.Add($"Error: {code}");
break;
case PaymentPending(var confirmation):
pendingMessages.Add($"Pending: {confirmation}");
break;
}
}
// Combine using collection expressions
string[] summary = [
$"Successes: {successMessages.Count}",
..successMessages,
$"Errors: {errorMessages.Count}",
..errorMessages,
$"Pending: {pendingMessages.Count}",
..pendingMessages
];
return summary;
}
}
// Usage
var service = new PaymentService();
var handler = new PaymentHandler();
var result = service.ProcessPayment(99.99m);
Console.WriteLine(handler.HandlePayment(result));
// Batch processing
PaymentResult[] batch = [
new PaymentSuccess("TX-001", 50m),
new PaymentFailed("ERR-001", "Declined"),
new PaymentPending("Confirm email")
];
string[] summary = handler.ProcessBatch(batch);
foreach (var line in summary)
{
Console.WriteLine(line);
}
Design benefits:
- Type safety: Can't miss a case
- Expressiveness: Intent is clear
- Maintainability: Adding a variant forces updates
- Pattern matching: Clean, readable code
Common Mistakes β οΈ
Mistake 1: Mutating Collection Expression Sources
β Wrong:
List<int> source = [1, 2, 3];
List<int> result = [..source];
source.Add(4);
// result is still [1, 2, 3] - it's a copy!
β Correct:
List<int> source = [1, 2, 3];
List<int> result = [..source];
// If you need a reference, don't use spread
List<int> reference = source; // Same list
source.Add(4);
// Now reference also has 4
Mistake 2: Primary Constructor Parameter Shadowing
β Wrong:
public class Product(string name, decimal price)
{
private string name = name.ToUpper(); // Field shadows parameter!
public void Display()
{
// Which 'name' is used here?
Console.WriteLine(name); // Could be confusing
}
}
β Correct:
public class Product(string name, decimal price)
{
private string _displayName = name.ToUpper(); // Different name
public void Display()
{
Console.WriteLine(_displayName); // Clear
}
}
Mistake 3: Forgetting Collection Expression Type Context
β Wrong:
var data = [1, 2, 3]; // Error: can't infer type
β Correct:
int[] data = [1, 2, 3]; // Explicit type
List<int> list = [1, 2, 3]; // Explicit type
var data2 = new int[] { 1, 2, 3 }; // Alternative
Mistake 4: Misusing Interceptors
β Wrong:
// Using interceptors for business logic
[InterceptsLocation("file.cs", 10, 5)]
public static int InterceptCalculation(int x)
{
return x * 2; // Don't use for runtime logic!
}
β Correct:
// Interceptors are for tooling/optimization
[InterceptsLocation("file.cs", 10, 5)]
public static int OptimizedMethod(int x)
{
// Use for compile-time transformations
// Generated by source generators
return CompiledOptimizedVersion(x);
}
Mistake 5: Not Handling All Union Cases
β Wrong:
public string Process(PaymentResult result)
{
return result switch
{
PaymentSuccess s => "Success",
PaymentFailed f => "Failed"
// Missing PaymentPending case!
};
}
β Correct:
public string Process(PaymentResult result)
{
return result switch
{
PaymentSuccess s => "Success",
PaymentFailed f => "Failed",
PaymentPending p => "Pending",
_ => throw new InvalidOperationException()
};
}
Key Takeaways π―
π Quick Reference Card
| Feature | Syntax | Key Benefit |
|---|---|---|
| Collection Expressions | [1, 2, ..other] | Unified collection creation |
| Spread Operator | ..collection | Inline expansion |
| Primary Constructors | class C(int x, int y) | Reduced boilerplate |
| Interceptors | [InterceptsLocation(...)] | Compile-time replacement |
| Extension Types | extension E for T { } | Add members to types |
| Discriminated Unions | union U { A, B, C } | Type-safe variants |
Essential principles:
- Collection expressions unify array, list, and span creation with
[]syntax - Spread operator (
..) combines collections without explicit loops - Primary constructors reduce boilerplate but don't create properties automatically
- Interceptors are for advanced scenarios and source generators, not regular code
- Extension types (proposed) will enable adding properties beyond methods
- Discriminated unions (proposed) enable exhaustive pattern matching
π‘ Pro tips:
- Use collection expressions for cleaner initialization code
- Leverage primary constructors for simple data classes
- Simulate discriminated unions with abstract records until native support arrives
- Follow C# evolution proposals on GitHub to stay informed
- Enable preview features carefully in production code
π§ Memory device for new features: "CPEID"
- Collection expressions
- Primary constructors
- Extension types
- Interceptors
- Discriminated unions
π Further Study
- C# Language Design GitHub - Official proposals and discussions
- Microsoft C# Documentation - What's new in each version
- .NET Blog - Announcements and deep dives into new features