George Kosmidis

Microsoft MVP | Speaks of Azure, AI & .NET | Founder of Munich .NET
Building tomorrow @
slalom
slalom

C# 10 – What’s new!

by George Kosmidis / Published 2 years and 5 months ago, modified 2 years and 3 months ago
C# 10 – What’s new!

C# 10 is here with new and exciting feature with an opt-in approach. In this guide we will go through some of the most important changes that will make your code cleaner, smaller, faster and will hopefully convince you to upgrade now!

Microsoft announce C# 10 a few days ago, on November 8th 2021, as part of .NET 6 and Visual Studio 2022 general availability. It cannot be installed on each own and it requires more than .NET 6 (also VS2022) because of the conditional, opt-in, features that were added. If you are not convinced yet by the fact that .NET 6 is the fastest ever .NET which massively lowers hosting costs, let’s take a closer look in C# 10 and you might change your mind!

Global and implicit usings

C# 10 includes a new global using directive and implicit usings to reduce the number of usings you need to specify at the top of each file.

Global using directives

If the keyword global appears prior to a using directive, that using applies to the entire project:

global using System;
global using static System.Console;
global using Env = System.Environment;

Note that you can use any feature of using within a global using directive.

You can put global using in any .cs file, including Program.cs or a specifically named file like globalusings.cs.

For more information, see global using directives.

Implicit usings

Implicit usings are not exactly implicit, since it’s a feature that automatically adds common global using directives for the type of project you are building. To enable implicit usings set the ImplicitUsings property in your .csproj file:

<PropertyGroup>
    <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

The specific set of global using directives included depend on the type of application you are building. For example, implicit usings for a console application or a class library are different than those for an ASP.NET application.

For more information, see this implicit global usings.

Combining using features

I am not a big fan of hidden code to be honest but this time, traditional using directives, global using directives, and implicit usings work well together. The problem is, that regardless how careful you are, the increased number of using directives that are injected without you controlling them in every .cs file, increase the chance of ambiguity in name resolution.

If you encounter this, besides adding aliases or reducing the number of namespaces you are importing, you have been given some tools to elegantly overcome it.

If you need to remove namespaces that have been included via implicit usings, you can specify them in your project file like this:

<ItemGroup>
  <Using Remove="System.Threading.Tasks" />
</ItemGroup>

You can also add namespace that behave as though they were global using directives, for example:

<ItemGroup>
  <Using Include="System.IO.Pipes" />
</ItemGroup>

File-scoped namespaces

Many files contain code for a single namespace. Starting in C# 10, you can include a namespace as a statement, followed by a semi-colon and without the curly brackets:

namespace MyCompany.MyNamespace;
class MyClass // Note: no indentation, no curly brackets
{
   //...
} 

This simplifies the code and removes a level of nesting. Only one file-scoped namespace declaration is allowed, and it must come before any types are declared.

Improvements for lambda expressions and method groups

C# 10 contains several improvements to both the types and the syntax surrounding lambdas.

Natural types for lambdas

Lambda expressions can have, from now on, a “natural” type, which means that the compiler can often infer the type of the lambda expression.

For example, up until C# 9 we had to convert a lambda expression to a delegate or an expression type (Func<> or Action<>), like this one:

Func<string, int> parse = (string s) => int.Parse(s);

Starting with C# 10, however, if a lambda does not have such a “target type” a computed will be used:

var parse = (string s) => int.Parse(s);

Hovering over var will show that the type is still Func<string, int>.

The compiler will use an available Func or Action delegate, if a suitable one exists. Otherwise, it will synthesize a delegate type (for example, when you have ref parameters or have a large number of parameters).

Unfortunately, not all lambdas have natural types because some just don’t have enough type information. For instance, leaving off parameter types will leave the compiler unable to decide which delegate type to use:

var parse = s => int.Parse(s); // ERROR: Not enough type info in the lambda

Finally, expression trees require “target” typing. If, for example, the target type is Expression and the lambda has a natural delegate type D an Expression<D> will be produced. For example:

LambdaExpression parseExpr = (string s) => int.Parse(s); // Expression<Func<string, int>>
Expression parseExpr = (string s) => int.Parse(s);       // Expression<Func<string, int>>

Natural types for method groups

Method groups can have a natural type if that group has only one overload (which is automatically chosen as the type). For example:

Func<int> read = Console.Read;
Action<string> write = Console.Write;

Can from now on be written:

var read = Console.Read; // Just one overload; Func<int> inferred
var write = Console.Write; // ERROR: Multiple overloads, can't choose

Return types for lambdas

When the return type of a lambda expression is obvious then it is just being inferred, but this is not always the case. For example the next code will throw an “Can’t infer return type” error.

var choose = (bool b) => b ? 1 : "two"; // ERROR: Can't infer return type

In C# 10 though, you can explicitly specify a return type on a lambda expression:

var choose = object (bool b) => b ? 1 : "two"; // Func<bool, object>

Attributes on lambdas

Starting in C# 10, you can put attributes on lambda expressions in the same way you do for methods and local functions. They go right where you expect; at the beginning. Once again, the lambda’s parameter list must be parenthesized when there are attributes:

Func<string, int> parse = [SomeAttribute(1)] (s) => int.Parse(s);
var choose = [SomeAttribute(2)][SomeAttribute(3)] object (bool b) => b ? 1 : "two";

Just like local functions, attributes can be applied to lambdas if they are valid on AttributeTargets.Method.

Attributes do not have any effect when the lambda is invoked, but are still useful for code analysis, and they are also emitted on the methods that the compiler generates under the hood for lambdas, so they can be discovered via reflection.

Improvements to structs

C# 10 introduces features for structs that provide better parity between structs and classes. These new features include parameterless constructors, field initializers, record structs and with expressions.

Parameterless struct constructors and field initializers

Prior to C# 10, a parameterless constructor for a struct was not possible, since every struct had an implicit public parameterless constructor that set the struct’s fields to default.

Starting in C# 10, you can optionally write your own parameterless struct constructor that sets fields to any value.

public struct Address
{
    public Address()
    {
        City = "<unknown>";
    }
    public string City { get; init; }
}

You can initialize fields in a parameterless constructor as above, or you can initialize them via field or property initializers:
public struct Address
{
    public string City { get; init; } = "<unknown>";
}

Structs that are created via default or as part of array allocation ignore explicit parameterless constructors, and always set struct members to their default values. For more information about parameterless constructors in structs, see the struct type.

record structs

Starting in C# 10, records can now be defined with record struct. These are similar to a record class that were introduced in C# 9:

public record struct Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

You can continue to define record classes with just record as they were introduced, or you can use record class for clarity.

Structs already had value equality, so when you compare them it is by value. Record structs add IEquatable<T> support and the == operator. Record structs provide a custom implementation of IEquatable<T> to avoid the performance issues of reflection, and they include record features like a ToString() override.

Record structs can be positional with a primary constructor implicitly declaring public members. The difference is though, that this time fields be read/write (unlike record classes):

public record struct Person(string FirstName, string LastName);

Implicitly created properties that are read/write, makes it easier to convert tuples to named types, clean up your code and guarantee consistent member names.

If you declare a property or field with the same name as a primary constructor parameter, no auto-property will be synthesized and yours will be used.

Immutability comes easy to a record struct, by adding the readonly keyword to that struct.

sealed modifier on ToString() in record classes

Starting in C# 10 the ToString() method can include the sealed modifier, which prevents the compiler from synthesizing a ToString implementation for any derived records.

with expressions on structs and anonymous types

C# 10 supports with expressions for all structs, including record structs, as well as for anonymous types:

var person = person with { LastName = "Kristensen" };

This returns a new instance with the new value. You can update any number of values. Values you do not set will retain the same value as the initial instance.

Interpolated string improvements

C# 10 contains a few improvements on interpolated strings, which make things faster!

Interpolated string handlers

Today the compiler turns interpolated strings into a call to string.Format. This can lead to a lot of allocations – the boxing of arguments, allocation of an argument array, and of course the resulting string itself. In C# 10 a library pattern was added that allows an API to “take over” the handling of an interpolated string argument expression.

As an example, consider StringBuilder.Append:

var sb = new StringBuilder();
sb.Append($"Hello {args[0]}, how are you?");

Up until now, this would call the Append(string? value) overload with a newly allocated and computed string, appending that to the StringBuilder in one chunk. From C# 10, the strings "Hello ", args[0] and ", how are you?" will be individually appended to the StringBuilder, which is much more efficient and has the same outcome.

String.Create()

String.Create() in C# 10 lets you specify the IFormatProvider used to format the expressions in the holes of the interpolated string argument itself:

String.Create(CultureInfo.InvariantCulture, $"The result is {result}");

You can learn more about interpolated string handlers, in this article and this tutorial on creating a custom handler.

Constant interpolated strings

If all the holes of an interpolated string are constant strings, then the resulting string is now also constant. This lets you use string interpolation syntax in more places, like attributes:

[Obsolete($"Call {nameof(Discard)} instead")]

Note that the holes must be filled with constant strings. Other types, like numeric or date values, cannot be used because they are sensitive to Culture, and can’t be computed at compile time.

Other improvements

C# 10 has a number of smaller improvements across the language. Some of these just make C# work in the way you expect.

Mix declarations and variables in deconstruction

Prior to C# 10, deconstruction required all variables to be new, or all of them to be previously declared. In C# 10, you can mix:

int x2;
int y2;
(x2, y2) = (0, 1);       // Works in C# 9
(var x, var y) = (0, 1); // Works in C# 9
(x2, var y3) = (0, 1);   // Works in C# 10 onwards

Extended property patterns

C# 10 adds extended property patterns to make it easier to access nested property values in patterns. For example, the following complex object can be pattern matched in both of the ways shown here:

object obj = new Person
{
    FirstName = "George",
    LastName = "Kosmidis",
    Address = new Address { City = "Munich" }
};

if (obj is Person { Address: { City: "Munich" } })
    Console.WriteLine("Munich");

if (obj is Person { Address.City: "Munich" }) // Extended property pattern
    Console.WriteLine("Munich");

The extended property pattern simplifies the code and makes it easier to read, particularly when matching against multiple properties.

Find out more about extended property patterns in the pattern matching article.

Not the end!

Although these were by far not all the changes C# 10 brings, they were the most important ones. Install .NET 6 or Visual Studio 2022, and comment out your favorite new feature!

This page is open source. Noticed a typo? Or something unclear?
Edit Page Create Issue Discuss
Microsoft MVP - George Kosmidis
Azure Architecture Icons - SVGs, PNGs and draw.io libraries