Most new language features are not for everyday uses but cover very specific problems and situations. But some of the new features found in version 8 through 14 can be very helpful in our everyday work. In this post we'd like to share some of the new features that we found helpful in our work.
Use of the switch Expression (8.0)
When a switch statement is used to assign a value to a single variable based on the different cases or when it is used to just return a value, then the new switch-expression can be used.
So instead of writing code like this:
public static RGBColor FromRainbowClassic(Rainbow colorBand)
{
switch (colorBand)
{
case Rainbow.Red:
return new RGBColor(0xFF, 0x00, 0x00);
case Rainbow.Orange:
return new RGBColor(0xFF, 0x7F, 0x00);
case Rainbow.Yellow:
return new RGBColor(0xFF, 0xFF, 0x00);
case Rainbow.Green:
return new RGBColor(0x00, 0xFF, 0x00);
case Rainbow.Blue:
return new RGBColor(0x00, 0x00, 0xFF);
case Rainbow.Indigo:
return new RGBColor(0x4B, 0x00, 0x82);
case Rainbow.Violet:
return new RGBColor(0x94, 0x00, 0xD3);
default:
throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand));
};
}
You can now write it that way:
public static RGBColor FromRainbow(Rainbow colorBand)
=> colorBand switch
{
Rainbow.Red => new RGBColor(0xFF, 0x00, 0x00),
Rainbow.Orange => new RGBColor(0xFF, 0x7F, 0x00),
Rainbow.Yellow => new RGBColor(0xFF, 0xFF, 0x00),
Rainbow.Green => new RGBColor(0x00, 0xFF, 0x00),
Rainbow.Blue => new RGBColor(0x00, 0x00, 0xFF),
Rainbow.Indigo => new RGBColor(0x4B, 0x00, 0x82),
Rainbow.Violet => new RGBColor(0x94, 0x00, 0xD3),
_ => throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand)),
};
Some changes can be seen when compared with the switch-statement:
- The variable that is used for switching comes before the
switchkeyword caseis replaced by=>defaultis replaced by_(discard)- Only expressions can be used in the cases
Enhancements to pattern matching
In C♯ 7 pattern matching was introduced. Since then we could do things like the following:
if (arg1 is ComplexObject complex)
{
internal = complex.SophisticatedAction();
}
instead of:
var complex = arg1 as ComplexObject;
if (complex != null) {
internal = complex.SophisticatedAction();
}
Property patterns (8.0)
Property pattern allow you to access properties of an object that you want to base a decision on, e.g. while using a switch statement.
Instead of doing this:
var state = location.State;
state switch
{
"WA" => salePrice * 0.08M;
"MN" => salePrice * 0.06M;
"MI" => salePrice * 0.09M;
_ => 0M;
}
With property patterns now this is possible:
location switch
{
{ State: "WA" } => salePrice * 0.08M;
{ State: "MN" } => salePrice * 0.06M;
{ State: "WI" } => salePrice * 0.09M;
}
Even something like this is possible with property patterns:
public static bool IsReleaseDate(DateTime date) => date is { Year: 2022, Month: 10, Day: 1 };
Extended with C♯ 10
With C♯ 10 the usage was improved further. Until then you had to write the following to include nested properties in your pattern:
if (e is MethodCallExpression { Method: { Name: "MethodName" } })
Now this is a bit shorter:
if (e is MethodCallExpression { Method.Name: "MethodName" })
Tuple patterns (8.0)
Sometimes you need to check the state of two variables at once for a decision. Maybe you would have used a nested if-statement to cover all cases. But with tuple patterns this is not needed anymore:
public static bool CanEditDocument(DocumentState state, UserRole role)
=> (state, role) switch
{
(DocumentState.Approval, UserRole.Approver) => true,
(DocumentState.Open, UserRole.Editor) => true,
(DocumentState.Open, UserRole.Manager) => true,
(DocumentState.Closed, UserRole.Reviewer) => true,
(_,_) => false,
}
The roles that may edit the document can clearly be seen here and the discard pattern covers all other cases where editing is not allowed.
Type patterns (9.0)
Type-checking can now also be done within a switch-expression using type patterns:
public abstract class Vehicle {}
public class Car : Vehicle {}
public class Truck : Vehicle {}
public static class TollCalculator
{
public static decimal CalculateToll(this Vehicle vehicle) => vehicle switch
{
Car _ => 2.00m,
Truck _ => 7.50m,
null => throw new ArgumentNullException(nameof(vehicle)),
_ => throw new ArgumentException("Unknown type of a vehicle", nameof(vehicle)),
};
}
Logical patterns and relational patterns (9.0)
Instead of long if-statements for each test you can now do such checks within a switch-expression as follows:
Console.WriteLine(GetCalendarSeason(new DateTime(2021, 3, 14))); // output: spring
Console.WriteLine(GetCalendarSeason(new DateTime(2021, 7, 19))); // output: summer
Console.WriteLine(GetCalendarSeason(new DateTime(2021, 2, 17))); // output: winter
static string GetCalendarSeason(DateTime date) => date.Month switch
{
>= 3 and < 6 => "spring",
>= 6 and < 9 => "summer",
>= 9 and < 12 => "autumn",
12 or (>= 1 and < 3) => "winter",
_ => throw new ArgumentOutOfRangeException(nameof(date), $"Date with unexpected month: {date.Month}."),
};
Here you can see the usage of relational patterns to check whether the month is in a specified ragne and the logical and and or is used to link the cases.
With the use of the logical not it is possible to write a more readable null-check like this:
if (input is not null)
{
// ...
}
List patterns (11.0)
With list patterns you can match arrays or lists against a sequence of patterns. This is especially useful when you want to check the structure of an input:
int[] numbers = { 1, 2, 3, 4, 5 };
var result = numbers switch
{
[1, 2, ..] => "Starts with 1, 2",
[.., 4, 5] => "Ends with 4, 5",
[_, _, _, ..] => "Has at least 3 elements",
[] => "Is empty",
_ => "Something else"
};
The slice pattern .. matches any number of elements (including zero). You can also combine it with a variable:
if (args is [var program, var filename, .. var rest])
{
Console.WriteLine($"Program: {program}, File: {filename}, Rest: {rest.Length} arguments");
}
This is particularly useful when parsing command line arguments or CSV data.
Raw String Literals (11.0)
When you need to write strings with many quotation marks, backslashes, or line breaks (e.g., JSON, XML, SQL, regex), it used to be cumbersome:
var json = @"{
""name"": ""John Doe"",
""email"": ""john@example.com""
}";
With Raw String Literals escaping is no longer needed. They start and end with at least three quotation marks:
var json = """
{
"name": "John Doe",
"email": "john@example.com"
}
""";
Indentation is automatically removed – the position of the closing """ determines how much whitespace is trimmed from the left.
For interpolation, use multiple $ characters to specify how many curly braces start an interpolation:
var json = $$"""
{
"name": "{{person.Name}}",
"count": {{items.Count}}
}
""";
With $$, {{...}} becomes the interpolation syntax, so single { and } in JSON don't need escaping.
Required members (11.0)
With init-only setters you can set properties only during initialization. But there was no way to ensure they were actually set. The required modifier enforces this:
public class Person
{
public required string Name { get; init; }
public required string Email { get; init; }
public int? Age { get; init; } // optional
}
var person = new Person { Name = "John" }; // Error! Email is missing
var person = new Person { Name = "John", Email = "john@example.com" }; // OK
This is especially useful for DTOs and configuration classes where certain properties must always be set.
Primary Constructors (12.0)
Classes and structs often required a lot of boilerplate code to inject dependencies:
public class UserService
{
private readonly ILogger _logger;
private readonly IUserRepository _repository;
public UserService(ILogger logger, IUserRepository repository)
{
_logger = logger;
_repository = repository;
}
public void CreateUser(string name) => _logger.Log($"Creating {name}");
}
With primary constructors this becomes much shorter:
public class UserService(ILogger logger, IUserRepository repository)
{
public void CreateUser(string name) => logger.Log($"Creating {name}");
}
The parameters are available throughout the entire class body. If they are only used for initialization, the compiler doesn't create a field for them.
Collection Expressions (12.0)
Initializing collections used to vary depending on the type:
int[] array = new int[] { 1, 2, 3 };
List<int> list = new List<int> { 1, 2, 3 };
Span<int> span = stackalloc int[] { 1, 2, 3 };
Now there's a unified syntax using square brackets:
int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
Particularly useful is the spread operator .. for combining collections:
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] combined = [..first, ..second]; // [1, 2, 3, 4, 5, 6]
// Also with additional elements
int[] withExtra = [0, ..first, 99]; // [0, 1, 2, 3, 99]
Type aliases for any type (12.0)
The using directive for type aliases was previously limited to named types. Now you can also alias tuples, arrays, and other types:
using Point = (int X, int Y);
using Coordinates = (double Latitude, double Longitude);
using Matrix = int[,];
Point origin = (0, 0);
Coordinates berlin = (52.52, 13.405);
This makes code more readable when working frequently with tuples.
Null-conditional assignment (14.0)
Previously, you had to write an if statement for conditional assignment:
if (customer is not null)
{
customer.LastOrder = GetCurrentOrder();
}
Now you can use the ?. operator on the left side of an assignment:
customer?.LastOrder = GetCurrentOrder();
The right side is only evaluated if customer is not null. This also works with compound assignments:
customer?.Balance += 100;
customer?.Orders?.Add(newOrder);
The field keyword (14.0)
When you need validation or logic in a property setter, you previously had to declare an explicit backing field:
private string _message;
public string Message
{
get => _message;
set => _message = value ?? throw new ArgumentNullException(nameof(value));
}
With the new field keyword you can access the compiler-generated backing field:
public string Message
{
get;
set => field = value ?? throw new ArgumentNullException(nameof(value));
}
This significantly reduces boilerplate code when you only need to customize one of the accessors.
Extension Members (14.0)
Extension methods have existed since C# 3.0 – but extension properties were not possible for a long time. C# 14 changes this with a new syntax for extension members:
public static class StringExtensions
{
extension(string s)
{
// Extension Property (NEW!)
public bool IsNullOrEmpty => string.IsNullOrEmpty(s);
// Extension Method (new syntax)
public string Truncate(int maxLength)
=> s.Length <= maxLength ? s : s[..maxLength] + "...";
}
}
Usage is as intuitive as with extension methods:
string name = "John Doe";
if (!name.IsNullOrEmpty) // Extension Property
{
Console.WriteLine(name.Truncate(10)); // "John Doe..."
}
Static extension members and operators are also possible:
public static class ListExtensions
{
extension<T>(IEnumerable<T>) // without variable name = static
{
// Static extension operator
public static IEnumerable<T> operator +(IEnumerable<T> left, IEnumerable<T> right)
=> left.Concat(right);
}
}
// Usage
var combined = list1 + list2; // instead of list1.Concat(list2)
This opens up new possibilities for more fluent APIs and expressive code.
Using declarations (8.0)
Often you start the body of a method or of an if-branch with an using-statement, thus adding another level of braces, like this:
static int WriteLinesToFile(IEnumerable<string> lines)
{
using (var file = new System.IO.StreamWriter("WriteLines2.txt"))
{
int skippedLines = 0;
foreach (string line in lines)
{
if (!line.Contains("Second"))
{
file.WriteLine(line);
}
else
{
skippedLines++;
}
}
return skippedLines;
} // file is disposed here
}
This can now be written as a using declaration:
static int WriteLinesToFile(IEnumerable<string> lines)
{
using var file = new System.IO.StreamWriter("WriteLines2.txt");
int skippedLines = 0;
foreach (string line in lines)
{
if (!line.Contains("Second"))
{
file.WriteLine(line);
}
else
{
skippedLines++;
}
}
return skippedLines;
// file is disposed here
}
This change reduces the amount of braces, the needed indentation and the number of lines within a method and file.
Indices and ranges (8.0)
To access specific elements within a sequence you can now use an improved syntax. Instead of writing
var wordBeforeLast = words[words.Length - 2];
you can now write:
var wordBeforeLast = words[^2];
The index from end operator acts the same as above, so ^0 would throw an exception (just as word[words.Length] would have).
And if you want to cut a specific subrange from a sequence you can now use the range operator:
var relevantPart = phoneNumber[4..9];
This would include the following elements: phoneNumber[4] to phonenumber[8] - so the beginning is included and the end is excluded. Both numbers are optional and if they are missing beginning or end of the sequence is assumed as the value. The index from end operator can also be used in ranges.
Null-coalescing assignment (8.0)
In methods where you have a parameter that defaults to null you may often have used the following construct to ensure a valid value for the parameter:
void Method(ComplexObject complex = null)
{
if (complex is null)
{
complex = new ComplexObject(...);
}
...
}
This can now be written a lot shorter:
void Method(ComplexObject complex = null)
{
complex ??= new ComplexObject(...);
...
}
init-only setters (9.0)
If you wanted to enable the setting of properties of an object using the property initializers you had to make the setter for each property public. This also enabled the setting of the value later on, which might have been undesirable in many places.
With the new init only setters this can be prevented. A property that is specified as such can only be initialized with a property initializer and not be set afterwards.
public class ComplexObject
{
public int ChildrenCount { get; init; }
public ComplexObject()
{
...
}
}
var complex = new ComplexObject { ChildrenCount = 20 };
complex.ChildrenCount = 25; // Error! CS8852 - this is not allowed
top level statements (9.0)
Typically a program has one main file, e.g. the Program.cs in ASP.Net Core applications. And this file used to have a lot of boilerplate code. Now one file in the project may omit all the boilerplate and have just the code that needs to be executed (plus the using statements).
So this:
namespace Company.Application
{
using System;
...
public static void Main(string[] args)
{
Console.WriteLine("Hello World!");
...
}
}
Would become this:
using System;
...
Console.WriteLine("Hello World!");
...
Omit the type when using new{style=margin-left: 0.2em} (9.0)
It is now possible to omit the type in a new expression when the created object's type is already known:
private List<Chemical> chemicals = new();
This is also possible for arguments when calling a method:
public TimeSpan GetReactionTime(Chemical secondChemical, ReactionOptions options) {...}
var duration = chemical.GetReactionTime(catalysator, new());
And you can combine this with init only properties as follows:
ReactionOptions options = new() { Technician = "Heinz Müller" };
File-scoped namespace declaration (10)
To further reduce the indentations within source files you can now use the file-scoped namespace declaration. This reduces code as:
namespace Company.Application
{
using System;
using Company.Business;
public class SpecialHandler
{
...
}
}
to this:
namespace Company.Application;
using System;
using Company.Business;
public class SpecialHandler
{
...
}
By using this you save space in both dimensions: indentation and count of lines.
Assignment and declaration in same deconstruction (10)
Since C♯ 7 you can deconstruct a user-defined object using a Deconstruct method. You could deconstruct and assign all values to new variables or to existing values. Now you can both things in one statement:
public class Point
{
...
public void Deconstruct(out int x, out int y) {...}
}
var point = new Point(...);
int x = 0;
(x, int y) = point;
These features help make code more readable, shorter, and more maintainable. Which of these features are you already using in your projects?