C# 10 Features: Writing Cleaner, More Expressive Code
C# 10 ships with .NET 6 and brings a collection of features focused on reducing boilerplate and improving expressiveness. Let’s explore what’s new.
Record Structs
C# 9 introduced records as reference types. C# 10 extends this to value types:
// Record struct - value type with value equality
public record struct Point(int X, int Y);
// Readonly record struct - immutable value type
public readonly record struct ImmutablePoint(int X, int Y);
// Usage
var p1 = new Point(10, 20);
var p2 = new Point(10, 20);
Console.WriteLine(p1 == p2); // True - value equality
// Record structs can be mutable
var p3 = new Point(5, 5);
p3.X = 10; // Allowed for non-readonly record struct
// Readonly record structs are immutable
var p4 = new ImmutablePoint(5, 5);
// p4.X = 10; // Compiler error
Struct Improvements
Structs gain parameterless constructors and field initializers:
public struct Configuration
{
// Field initializers
public string Environment { get; init; } = "Development";
public int Timeout { get; init; } = 30;
public bool EnableLogging { get; init; } = true;
// Parameterless constructor
public Configuration()
{
// Can add additional logic
Console.WriteLine("Configuration initialized");
}
}
// Usage
var config = new Configuration();
Console.WriteLine(config.Environment); // "Development"
Console.WriteLine(config.Timeout); // 30
var prodConfig = new Configuration
{
Environment = "Production",
Timeout = 60
};
Global Using Directives
Eliminate repetitive using statements across your codebase:
// GlobalUsings.cs - place at project root
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Net.Http;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
// Now every file in the project has access to these namespaces
Or configure in your project file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Using Include="System.Text.Json" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="MyCompany.Common" />
</ItemGroup>
</Project>
File-Scoped Namespaces
Save one level of indentation in every file:
// Before - C# 9 and earlier
namespace MyCompany.MyProject.Services
{
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Order> GetOrderAsync(int id)
{
return await _repository.GetByIdAsync(id);
}
}
}
// After - C# 10 file-scoped namespace
namespace MyCompany.MyProject.Services;
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Order> GetOrderAsync(int id)
{
return await _repository.GetByIdAsync(id);
}
}
Extended Property Patterns
Pattern matching gets more concise for nested properties:
public record Address(string City, string Country, string PostalCode);
public record Person(string Name, Address Address);
// Before - C# 9
bool IsFromSeattle(Person person) => person is
{
Address: { City: "Seattle" }
};
// After - C# 10 extended property patterns
bool IsFromSeattleV2(Person person) => person is
{
Address.City: "Seattle"
};
// More complex example
bool IsAustralianMetro(Person person) => person is
{
Address.Country: "Australia",
Address.City: "Sydney" or "Melbourne" or "Brisbane"
};
// In switch expressions
string GetShippingZone(Person person) => person switch
{
{ Address.Country: "Australia", Address.PostalCode: [>= '0' and <= '2', ..] } => "NSW/ACT",
{ Address.Country: "Australia", Address.PostalCode: ['3', ..] } => "Victoria",
{ Address.Country: "Australia" } => "Other AU",
{ Address.Country: "New Zealand" } => "NZ",
_ => "International"
};
Constant Interpolated Strings
Interpolated strings can now be constants when all components are constants:
const string Scheme = "https";
const string Host = "api.example.com";
const string Version = "v1";
// This now works in C# 10
const string BaseUrl = $"{Scheme}://{Host}/{Version}";
// Useful for attributes
[Route($"{Version}/[controller]")]
public class UsersController : ControllerBase
{
// Controller implementation
}
// And in switch expressions
string GetEndpoint(string resource) => resource switch
{
"users" => $"{BaseUrl}/users",
"orders" => $"{BaseUrl}/orders",
_ => throw new ArgumentException($"Unknown resource: {resource}")
};
Lambda Improvements
Lambdas gain natural types, attributes, and explicit return types:
// Natural type - compiler infers delegate type
var square = (int x) => x * x;
var greet = () => "Hello, World!";
// Explicit return type
var parse = object (string s) => int.Parse(s);
// Attributes on lambdas
var validate = [Required][StringLength(100)] (string input) =>
!string.IsNullOrEmpty(input);
// Method group improvements
var numbers = new[] { 1, 2, 3, 4, 5 };
var doubled = numbers.Select(Double); // Natural type inference
static int Double(int x) => x * 2;
// Useful for minimal APIs
app.MapGet("/users", [Authorize] async (UserService service) =>
await service.GetAllUsersAsync());
CallerArgumentExpression
Capture the expression passed to a parameter:
using System.Runtime.CompilerServices;
public static class Guard
{
public static void NotNull<T>(
T? value,
[CallerArgumentExpression("value")] string? expression = null)
{
if (value is null)
{
throw new ArgumentNullException(expression);
}
}
public static void InRange(
int value,
int min,
int max,
[CallerArgumentExpression("value")] string? expression = null)
{
if (value < min || value > max)
{
throw new ArgumentOutOfRangeException(
expression,
$"Value {value} must be between {min} and {max}");
}
}
}
// Usage
public void ProcessOrder(Order? order, int quantity)
{
Guard.NotNull(order); // Exception: "order"
Guard.InRange(quantity, 1, 100); // Exception: "quantity"
// Process the order...
}
Async Method Builders
Custom async method builders for specialized scenarios:
// Attribute to specify custom builder
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
public async ValueTask<int> ComputeAsync()
{
await Task.Delay(100);
return 42;
}
// Useful for high-performance scenarios where you want to pool
// the state machine allocations
Enhanced #line Directives
Better source mapping for code generators:
// Supports column numbers and character spans
#line (1, 1) - (1, 10) "generated.cs"
var x = 10;
// Useful for source generators and transpilers
// to provide accurate debugging information
Putting It All Together
Here’s a complete example showcasing multiple C# 10 features:
global using System.Text.Json;
namespace MyApp.Models;
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Zero(string currency) => new(0, currency);
public Money Add(Money other) => other switch
{
{ Currency: var c } when c == Currency => this with { Amount = Amount + other.Amount },
_ => throw new InvalidOperationException($"Cannot add {other.Currency} to {Currency}")
};
}
public record Order(int Id, Money Total, Address ShippingAddress)
{
public bool IsInternational => ShippingAddress.Country is not "Australia";
public string GetShippingTier() => this switch
{
{ Total.Amount: > 100, ShippingAddress.Country: "Australia" } => "Free",
{ ShippingAddress.Country: "Australia" } => "Standard",
_ => "International"
};
}
public record Address(string Street, string City, string Country);
C# 10 continues the trend of making the language more expressive while reducing boilerplate. Combined with .NET 6’s performance improvements, it’s a great time to be a C# developer.