A recap of modern C# features for the busy enterprise developer

Introduction

In this article I'll try to recap some of the more useful C# features to come out in the past 5 years, starting with C# 8.0. I picked C# 8 specifically because from my personal experience most enterprise C# developers are comfortable using features up until C# 7 including async-await, but are mostly oblivious to some really nice improvements that were made to the language afterwards.

The features I list here are in no particular order and I'll cover every feature I found useful up to and including C# 11 features.

Records

Starting off with my personal favorite: records! Records are an alternate way of representing reference or value types, which comes with:

  • Immutability by default
  • Value semantics
  • Nondestructive mutations via the with keyword

You can declare records in multiple ways:

public record Person(string FirstName, string LastName);

public record Person
{
    public string FirstName { get; init; } = default!;
    public string LastName { get; init; } = default!;
};

// Note that the properties are mutable here
public record Person
{
  public string FirstName { get; set; } = default!;
  public string LastName { get; set; } = default!;
};

// You can also declare records as value types
public readonly record struct Point(double X, double Y, double Z);

By default records are immutable, as in the values behind the reference cannot be mutated. You can however create new copies of a record using the with keyword:

public record Person(string FirstName, string LastName);

var person1 = new Person("John", "Doe");
var person2 = new Person("Jane", "Doe");
var person3 = person1 with { FirstName = "Jane" };

Console.WriteLine(person1); // Person { FirstName = John, LastName = Doe }
Console.WriteLine(person2 == person3); // True

Records also come with deconstruction patterns by default:

var person1 = new Person("John", "Doe");
var (firstName, _) = person1;

Console.WriteLine(firstName); // prints: John

For those who've experienced functional languages such as F#, Haskell or Scala, the idea of data structures being immutable by default is not a new one. C# introducing first-class support for immutable data structures is a big leap forward towards a future where functional modelling is possible in C# without having to use somewhat clunky third-party libraries.

The only real downside of records I've experienced so far is that they don't mesh with Entity Framework at all, but ultimately you can work around this particular limitation by introducing an intermediate data representation layer.

For more information on record see this MSDN page.

Pattern Matching

Another new C# feature that takes the language in a functional direction is pattern matching.

record Customer(int Id, string FirstName, string LastName);
record Order(int Items, double Cost, Customer Customer);

double CalculateDiscount(Order order) =>
    order switch
    {
        { Customer.FirstName: "Jane" } => 0.25,
        { Cost: > 500 } => 0.1,
        { Items: > 10  } => 0.05,
        _ => 0.0
    };

As of C# 11 and the introduction of list patterns, C#'s pattern matching covers most common use cases, enabling very concise, data-driven solutions for certain problems.

For an exhaustive list of currently supported patterns see this MSDN page and for a more practical application of pattern matching see this MSDN tutorial.

Nullable reference types

The existence of null values is often called the billion-dollar mistake and for a very good reason. Even with the introduction of the null coalescing operator, dealing with nulls is still incredibly tedious and reasoning about why you're getting a null is even harder.

Certain programming languages such as Rust work around the null problem with the Option<T> abstraction having first-class support in both the language and its standard library. For C# however language and standard library support of options is not very realistic, but C# 9 introduces the concept of nullable reference types (or NRTs) to the language.

NRT support can be enabled at the project level by adding <Nullable>enable</Nullable> to the .csproj file. Once enabled, assigning a null or a potential null to a reference type will emit a compiler warning:

// emits [CS8600] Converting null literal or possible null value to non-nullable type.
string name = null;

// This compiles without warnings
string? nullableName = null;

So does this solve the null problem? To an extent yes, but realistically even if you enable NRTs on all your projects you'll still need to be disciplined in order to avoid a disaster involving the ! operator that you'll inevitably have to use.

I personally prefer using the language-ext functional library and its Option<T> abstraction instead, but admittedly that's a much bigger leap for most C# project than NRTs.

Indices and ranges

Having used Python extensively for automation work I always loved its fantastic indexing and range support. C# 8 introduced a similar way of indexing array-like objects:

var array = new[] { 1, 2, 3, 4, 5 };

// The unary `^` operator means 'from end' as in `array.Length - i` inclusive.
var slice1 = array[..2];    // { 1, 2 } -> equivalent to array[0..2]
var slice2 = array[2..^3];  // { }
var slice3 = array[..^3];   // { 1, 2 }
var slice4 = array[^2..];   // { 3, 4, 5 }
var slice5 = array[..];     // { 1, 2, 3, 4, 5 }

You can also explicitly declare ranges and reuse them in your code:

var array = new[] { 1, 2, 3, 4, 5 };

var range = new Range(2, Index.End);
var rangeFromEnd = new Range(Index.FromEnd(2), Index.End);

Console.WriteLine(array[range]);        // { 3, 4, 5 }
Console.WriteLine(array[rangeFromEnd]); // { 4, 5 }

Overall I really like this vastly improved indexing scheme, even if it doesn't quite reach the heights of Python's solution. Admittedly though the indexing limitations of C# mostly come down to the fundamentally different implementation of C#'s and Python's array-like objects.

Span<T>

Unlike every other item in this article, Span<T> isn't a language-level change, but it is an important concept to be aware of nonetheless. The MSND definition of Span<T> reads: "a type-safe and memory-safe representation of a contiguous region of arbitrary memory".

That definition sounds straightforward enough, but what's the point of Span<T> in practice? A somewhat common use case would be operating on a large number of strings while avoiding the allocation of new strings. To demonstrate this use case I'll take the somewhat contrived example from MSDN:

private const string Content = "Content-Length: 132";

[Benchmark]
public int GetContentLengthWithSpan()
{
    var slice = Content.AsSpan().Slice(16);

    return int.Parse(slice);
}

[Benchmark]
public int GetContentLengthWithSubstring()
{
    var slice = Content.Substring(16);

    return int.Parse(slice);
}

Running these methods with BenchmarkDotnet gives the following results:

|                        Method |      Mean |     Error |    StdDev |  Gen 0 | Allocated |
|------------------------------ |----------:|----------:|----------:|-------:|----------:|
|      GetContentLengthWithSpan |  9.816 ns | 0.0794 ns | 0.0743 ns |      - |         - |
| GetContentLengthWithSubstring | 16.301 ns | 0.0868 ns | 0.0770 ns | 0.0019 |      32 B |

The function using .Substring() is 60% slower than the one using .AsSpan().Slice(). Granted the actual difference here is ~6ns, which in the grand scheme of things is completely negligible if you don't run use function on a hot path. If you do however operate on strings, or any array-like objects frequently then being aware of the existence and use cases of Span<T> can be crucial.

Init only setters

The first section on records used init only setters, but I think they're worth mentioning explicitly. Using init instead of set on a property ensures that said property can only be changed under the following conditions (exhaustive list from MSDN):

  • During an object initializer
  • During a with expression initializer
  • Inside an instance constructor of the containing or derived type, on this or base
  • Inside the init accessor of any property, on this or base
  • Inside attribute usages with named parameters

The main point of this particular feature is that it reduces a lot of property-related boilerplate. Achieving the same get-only semantics prior to the introduction of init required the implementation of an explicit constructor.

Top level statements

Top level statements is another feature that reduces boilerplate significantly in the entry points of applications:

partial class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
}

Becomes the following with top level statements:

Console.WriteLine("Hello World");

A frequent application of this particular feature is in ASP.NET Core's service startup -- see here on MSDN. Apparently top level statements are somewhat divisive in the C# community, although I'm not sure why. I personally really like not having boilerplate in my code.

Using and namespace improvements

The handling of usages and namespaces have been significantly improved over multiple C# releases. C# 10 introduced file scoped namespaces, which reduces the nesting of C# code by a level:

namespace Name // nested namespaces
{
    class C
    {
    }
}

namespace Name; // this if semantically equivalent to the prior solution

class C
{
}

C# 10 also came with multiple changes that impacted usings. First of all frequently used System.* are automatically referenced globally in your projects. Second of all the global using keyword combination was introduced, which allows the creation of assembly-level files where you can declare the usings you'd like every file in the project to see like so:

global using FluentAssertions;
global using Xunit;
global using Moq;
global using static LanguageExt.Prelude;

This particular example is from a test project of mine, where it makes perfect sense to globally import those namespaces, considering they're used in just about every file! Obviously global using should only really be used on namespaces that really are referenced frequently, otherwise you're not really reducing the noise produced by using statements.

Improvements to lambda expressions

Prior to C# 10, declaring new lambdas in places where the compiler couldn't infer their types was rather verbose (and quite unintuitive) compared to... well, just about any modern programming language out there. Consider this:

var add = new Func<int, int, int>((a, b) => a + b);

and now compare it to TypeScript:

const add = (a: number, b: number) => a + b;

... or F#:

let add = fun (a, b) -> a + b

Thankfully, after some improvements C# lambda declarations look quite nice:

var add = (int a, int b) => a + b;

Also, while technically you could use discards prior to C# 9, they weren't really discard as much as variables named _. Now we have genuine discards in C#

var zero = (int _, int _) => 0

Now that we have records, decent pattern matching, cleaner lambdas and discard all we need is first-class support for partial application of functions and discriminated unions, and then we have a truly functional language in C#.

Conclusion

That covers just about every new C# feature from the past 5 years that I use daily. Obviously this list is not exhaustive, so if you're curious about every new things that was added to the language you should consult the C# Version History MSDN entry.