C# 10 has been released with .NET 6 and VS2022. This article organizes some interesting syntax features of C# according to the release order of .NET, based on the official Microsoft documentation.
Note: Projects created on different .NET platforms support different default C# versions. The syntax features introduced below will specify the C# version in which they were introduced. When using them, pay attention to whether your C# version supports the corresponding feature. For C# language version control, refer to the official documentation.
Anonymous Functions
Anonymous functions are a feature introduced in C# 2. As the name implies, anonymous functions have a method body but no name. Anonymous functions are created using the delegate keyword and can be converted to delegates. Anonymous functions do not need to specify a return type; it is inferred automatically from the return statement.
Note: Lambda expressions were introduced in C# 3. Lambda expressions provide a more concise way to create anonymous functions and should be preferred. Unlike lambdas, anonymous functions created with delegate can omit the parameter list and can be converted to delegate types with any parameter list.
// Created using the delegate keyword, no return type needed, convertible to delegate, parameter list can be omitted (unlike lambda)
Func<int, bool> func = delegate { return true; };
Auto-Implemented Properties
Starting from C# 3, when no additional logic is needed in property accessors, you can use auto-implemented properties to declare properties more concisely. At compile time, the compiler creates a private, anonymous backing field that can only be accessed through the property's get and set accessors. When developing with VS, you can quickly generate an auto-implemented property using the snippet prop + two tabs.
// Old way
private string _name;
public string Name
{
get { return _name; }
set { _name = value; }
}
// Auto-implemented property
public string Name { get; set; }
Additionally, starting from C# 6, you can initialize auto-implemented properties:
public string Name { get; set; } = "Louzi";
Anonymous Types
Anonymous types were introduced in C# 3. They encapsulate a set of read-only properties into a single object without requiring an explicit type definition. The compiler infers the type of each property and generates the type name. From the CLR perspective, anonymous types are no different from other reference types and derive directly from object. If two or more anonymous objects have properties with the same order, names, and types, the compiler treats them as instances of the same type. When creating an anonymous type, if you do not specify member names, the compiler uses the name of the property used for initialization.
Anonymous types are commonly used in select query expressions in LINQ queries. Anonymous types are created using new with an initializer list:
// Create anonymous type using new with initializer list
var person = new { Name = "Louzi", Age = 18 };
Console.WriteLine($"Name: {person.Name}, Age: {person.Age}");
// Used in LINQ
var productQuery =
from prod in products
select new { prod.Color, prod.Price };
foreach (var v in productQuery)
{
Console.WriteLine("Color={0}, Price={1}", v.Color, v.Price);
}
LINQ
C# 3 introduced a killer feature: query expressions, namely Language Integrated Query (LINQ). Query expressions represent queries using query syntax, composed of a set of clauses similar to SQL.
Query expressions must begin with a from clause and end with a select or group clause. Between the first from clause and the last select or group clause, you can include: where, orderby, join, let, additional from clauses, etc.
You can perform LINQ queries on SQL databases, XML documents, ADO.NET datasets, and collection objects that implement IEnumerable or IEnumerable<T>.
A complete query includes creating a data source, defining a query expression, and executing the query. The query expression variable holds the query, not the query results. The query is executed only when you iterate over the query variable.
Any query that can be expressed with query syntax can also be expressed with method syntax. It is recommended to use the more readable query syntax. Some query operations (such as Count or Max) do not have equivalent query expression clauses and must be called as methods. You can combine method calls with query syntax.
For detailed documentation on LINQ, see the official Microsoft documentation.
// Data source.
int[] scores = { 90, 71, 82, 93, 75, 82 };
// Query Expression.
IEnumerable<int> scoreQuery = //query variable
from score in scores //required
where score > 80 // optional
orderby score descending // optional
select score; //must end with select or group
// Execute the query to produce the results
foreach (int testScore in scoreQuery)
{
Console.WriteLine(testScore);
}
Lambda
C# 3 introduced many powerful features, such as auto-implemented properties, extension methods, implicit types, LINQ, and Lambda expressions.
To create a Lambda expression, specify input parameters on the left side of => (empty parentheses for zero parameters, parentheses can be omitted for one parameter), and an expression or statement block on the right side (usually two or three statements). Any Lambda expression can be converted to a delegate type. Expression lambdas can also be converted to expression trees (statement lambdas cannot).
Anonymous functions can omit the parameter list; unused parameters in a Lambda can be specified using discards (C# 9).
Using async and await, you can create Lambda expressions and statements that contain asynchronous processing (C# 5).
Starting from C# 10, when the compiler cannot infer the return type, you can specify the return type of a Lambda expression before the parameters. In this case, the parameters must be enclosed in parentheses.
// Lambda converted to delegate
Func<int, int> square = x => x * x;
// Lambda converted to expression tree
System.Linq.Expressions.Expression<Func<int, int>> e = x => x * x;
// Use discards to specify unused parameters
Func<int, int, int> constant = (_, _) => 42;
// Async Lambda
var lambdaAsync = async () => await JustDelayAsync();
Console.WriteLine($"main thread id: {Thread.CurrentThread.ManagedThreadId}");
lambdaAsync();
static async Task JustDelayAsync()
{
await Task.Delay(1000);
Console.WriteLine($"JustDelayAsync thread id: {Thread.CurrentThread.ManagedThreadId}");
}
// Specify return type, without it would cause error
var choose = object (bool b) => b ? 1 : "two";
Extension Methods
Extension methods are also a feature introduced in C# 3. They allow you to add methods to existing types without modifying the original type. Extension methods are static methods, but they are called using instance object syntax. Their first parameter specifies the type the method operates on, prefixed with this. When compiled to IL, the compiler converts them to static method calls.
If a type has a method with the same name and signature as an extension method, the compiler will choose the type's method. When the compiler looks for a method, it first searches the type's instance methods, and if not found, it searches the extension methods for that type.
The most common extension methods are LINQ, which adds query functionality to the existing System.Collections.IEnumerable and System.Collections.Generic.IEnumerable<T> types.
When adding extension methods to a struct, because it is passed by value, only a copy of the struct object can be modified. Starting from C# 7.2, you can add the ref modifier to the first parameter to pass by reference, allowing modification of the struct object itself.
static class MyExtensions
{
public static void OutputStringExtension(this string s) => Console.WriteLine($"output: {s}");
public static void OutputPointExtension(this Point p)
{
p.X = 10;
p.Y = 10;
Console.WriteLine($"output: ({p.X}, {p.Y})");
}
public static void OutputPointWithRefExtension(ref this Point p)
{
p.X = 20;
p.Y = 20;
Console.WriteLine($"output: ({p.X}, {p.Y})");
}
}
// Class extension method
"Louzi".OutputStringExtension();
// Struct extension method
Point p = new Point(5, 5);
p.OutputPointExtension(); // output: (10, 10)
Console.WriteLine($"original point: ({p.X}, {p.Y})"); // output: (5, 5)
p.OutputPointWithRefExtension(); // output: (20, 20)
Console.WriteLine($"original point: ({p.X}, {p.Y})"); // output: (20, 20)
Implicitly Typed Local Variables (var)
Starting from C# 3, you can declare implicitly typed local variables (var) within a method scope. Implicitly typed variables are strongly typed, with the type determined by the compiler.
var is often used when calling a constructor to create an object instance. Starting from C# 9, this scenario can also use a new expression with a known type:
// Implicitly typed
var s = new List<int>();
// new expression
List<int> ss = new();
Note: When returning an anonymous type, you must use var.
Object and Collection Initializers
Starting from C# 3, you can instantiate an object or collection and perform member assignments in a single statement.
With object initializers, you can assign values to any accessible fields or properties of an object at creation time. You can specify constructor arguments or omit arguments and parentheses.
public class Person
{
// Auto-implemented property
public int Age { get; set; }
public string Name { get; set; }
public Person() { }
public Person(string name)
{
Name = name;
}
}
var p1 = new Person { Age = 18, Name = "Louzi" };
var p2 = new Person("Sherilyn") { Age = 18 };
Starting from C# 6, object initializers can also set indexers in addition to accessible fields and properties.
public class MyIntArray
{
public int CurrentIndex { get; set; }
public int[] data = new int[3];
public int this[int index]
{
get => data[index];
set => data[index] = value;
}
}
var myArray = new MyIntArray { [0] = 1, [1] = 3, [2] = 5, CurrentIndex = 0 };
Collection initializers allow you to specify one or more element initializers:
var persons = new List<Person>
{
new Person { Age = 18, Name = "Louzi" },
new Person { Age = 18, Name = "Sherilyn" }
};
Built-in Generic Delegates
.NET Framework 3.5 and 4.0 introduced built-in generic delegate types Action and Func. For delegates that return void, you can use the Action type. Action variants can have up to 16 parameters. For delegates that return a value, you can use the Func type. Func variants also have up to 16 parameters, with the return type being the last type parameter in the Func declaration.
Action<int> actionInstance = ActionInstance;
Func<int, string> funcInstance = FuncInstance;
static void ActionInstance(int n) => Console.WriteLine($"input: {n}");
static string FuncInstance(int n) => $"param: {n}";
dynamic
The main feature introduced in C# 4 is the dynamic keyword. The dynamic type bypasses compile-time type checking when variables are used and their members are referenced, deferring resolution until runtime. This enables constructs similar to dynamically typed languages (like JavaScript).
dynamic dyn = 1;
Console.WriteLine(dyn.GetType()); // output: System.Int32
dyn = dyn + 3; // If dyn were object, this line would error
Named and Optional Arguments
C# 4 introduced named and optional arguments. Named arguments allow you to specify an argument for a parameter by matching the argument with the parameter's name, without needing to match the position in the parameter list. Optional arguments allow you to omit arguments by specifying default values for parameters. Optional parameters must appear at the end of the parameter list; if you provide an argument for any of a series of optional parameters, you must provide arguments for all preceding optional parameters.
You can also declare optional parameters using the OptionalAttribute, in which case you do not need to provide a default value for the parameter.
// Named and optional arguments
PrintPerson(age: 18, name: "Louzi");
// static void PrintPerson(string name, int age, [Optional, DefaultParameterValue("男")] string sex)
static void PrintPerson(string name, int age, string sex = "男") =>
Console.WriteLine($"name: {name}, age: {age}, sex: {sex}");
Static Import
C# 6 introduced the static import feature. Using the using static directive to import a type allows you to access its static members and nested types without specifying the type name, avoiding obscure code that repeats type names.
using static System.Console;
WriteLine("Hello CSharp");
Exception Filters (when)
Starting from C# 6, when can be used in catch statements to specify a condition expression that must be true for the specific exception handler to execute. If the expression evaluates to false, the exception handler is not executed.
public static async Task<string> MakeRequest()
{
var client = new HttpClient();
var streamTask = client.GetStringAsync("https://localHost:10000");
try
{
var responseText = await streamTask;
return responseText;
}
catch (HttpRequestException e) when (e.Message.Contains("301"))
{
return "Site Moved";
}
catch (HttpRequestException e) when (e.Message.Contains("404"))
{
return "Page Not Found";
}
catch (HttpRequestException e)
{
return e.Message;
}
}
Auto-Property Initializer
Starting from C# 6, you can assign an initial value to an auto-implemented property to use something other than the type's default value:
public class DefaultValueOfProperty
{
public string MyProperty { get; set; } = "Property";
}
Expression-Bodied Members
Starting from C# 6, expression-bodied definitions are supported for methods, operators, and read-only properties. Starting from C# 7.0, expression-bodied definitions are supported for constructors, finalizers, properties, and indexers.
static void NewLine() => Console.WriteLine();
Null-Conditional Operator
Starting from C# 6, the null-conditional operator applies member access (?.) or element access (?[]) to its operand only if that operand evaluates to non-null; otherwise, it returns null.
// Null-conditional expression
public class ConditionalNull
{
event EventHandler AEvent;
public void RaiseAEvent() => AEvent?.Invoke(this, EventArgs.Empty);
}
Interpolated Strings
Starting from C# 6, you can use $ to insert expressions into strings, making code more readable and reducing the chance of string concatenation errors. To include braces in an interpolated string, use two braces ({{ or }}). If the interpolated expression uses a conditional operator, it must be enclosed in parentheses. Starting from C# 8, you can use $@"..." or @$"..." for interpolated verbatim strings. In earlier versions, you had to use the $@"..." form.
Console.WriteLine($"{name} is {age} year{(age == 1 ? "" : "s")} old.");
nameof
C# 6 provides the nameof expression. nameof yields the name (unqualified) of a variable, type, or member as a string constant.
public string Name
{
get => name;
set => name = value ?? throw new ArgumentNullException(nameof(value), $"{nameof(Name)} cannot be null");
}
out Variable Improvements
C# 7.0 improved the out syntax by allowing you to declare an out variable directly in the argument list of a method call, without needing a separate declaration statement:
void Function(out int arg) { ... }
// Before improvement
int n;
Function(out n);
// After improvement
Function(out int n);
Tuples
C# 7.0 introduced language support for tuples (previous versions had tuples but they were inefficient). Tuples can represent simple structures containing multiple data without needing a dedicated class or struct. Tuples are value types; they are lightweight data structures containing multiple public fields to represent data members and cannot define methods. Since C# 7.3, tuples support == and !=.
// Method 1: Use default field names: Item1, Item2, Item3, etc.
(string, string) unnamedLetters = ("a", "b");
Console.WriteLine($"{unnamedLetters.Item1}, {unnamedLetters.Item2}");
// Method 2
(string Alpha, string Beta) namedLetters = ("a", "b");
Console.WriteLine($"{namedLetters.Alpha}, {namedLetters.Beta}");
// Method 3
var alphabetStart = (Alpha: "a", Beta: "b");
Console.WriteLine($"{alphabetStart.Alpha}, {alphabetStart.Beta}");
// Method 4: Starting from C# 7.1, automatic inference of variable names is supported
int count = 5;
string label = "Colors used in the map";
var pair = (count, label); // Tuple element names are "count" and "label"
When a method returns a tuple, you can extract tuple members by declaring separate variables for each value, known as deconstructing the tuple. Using tuples as method return types can replace defining out parameters.
// Deconstructing a tuple
var (first, last) = Range(numbers);
Console.WriteLine($"{first} to {last}");
(int max, int min) = Range(numbers);
Console.WriteLine($"{min} to {max}");
Discards
Discards are supported starting from C# 7.0. Discards are placeholder variables that act as unassigned variables; they indicate that you do not intend to use that variable. The underscore _ represents a discard variable. The following are some scenarios where discards are used:
// Scenario 1: Discard tuple values
(_, _, area) = city.GetCityInformation(cityName);
// Scenario 2: Starting from C# 9, you can discard parameters in lambda expressions
Func<int, int, int> constant = (_, _) => 42;
// Scenario 3: Discard out parameters
DiscardsOut(out _);
static void DiscardsOut(out string s)
{
s = "nothing";
Console.WriteLine($"input is {s}");
}
Pattern Matching
C# 7.0 added pattern matching, and each subsequent major C# version has extended pattern matching. Pattern matching tests whether an expression has certain characteristics. is expressions, switch statements, and switch expressions all support pattern matching. The when keyword can be used to specify additional rules for a pattern.
Pattern matching currently includes these types: declaration pattern, type pattern, constant pattern, relational pattern, logical pattern, property pattern, positional pattern, var pattern, discard pattern. For details, refer to the official documentation.
The is pattern expression improves the functionality of the is operator, allowing assignment of the result in a single instruction:
// is pattern matching
if (input is int count) do something... ;
// Old way
if (input is int)
{
int count = (int)input;
do something... ;
}
// is pattern for null check
string? message = "This is not the null string";
if (message is not null) Console.WriteLine(message);
Default Text Expressions
Default value expressions produce the default value of a type. Earlier versions only supported the default operator. Starting from C# 7.1, the default expression is enhanced so that when the compiler can infer the type of the expression, you can use default to produce the default value of the type.
// New way
Func<string, bool> whereClause = default;
// Old way
Func<string, bool> whereClause = default(Func<string, bool>);
switch Expressions
Starting from C# 8, you can use switch expressions. The improvements of switch expressions over switch statements are:
- The variable comes before the
switchkeyword; =>replacescase :structure;- The discard
_replacesdefault; - Expressions replace statements.
public enum Level
{
One,
Two,
Three
}
public static int LevelToScore(Level level) => level switch
{
Level.One => 1,
Level.Two => 5,
Level.Three => 10,
_ => throw new ArgumentOutOfRangeException(nameof(level), $"Not expected level value: {level}"),
};
using Declaration
C# 8 added the using declaration feature. It tells the compiler that the declared variable should be disposed at the end of the enclosing block. The using declaration is more concise than the traditional using statement. Both approaches cause the compiler to call Dispose() at the end of the block.
static void WriteLinesToFile(IEnumerable<string> lines)
{
using var file = new System.IO.StreamWriter("WriteLines.txt");
do something... ;
return;
// file is disposed here
}
Indices and Ranges
C# 8 added indices and ranges, providing a concise syntax for accessing single elements or ranges in a sequence. The syntax relies on two new types and two new operators:
System.Indexrepresents an index into a sequence;System.Rangerepresents a subrange of a sequence;- The hat operator
^specifies the nth from the end; - The range operator
..specifies the start and end of a range.
The range operator includes the start but excludes the end.
var words = new string[]
{ // Regular index Index from end
"The", // 0 ^9
"quick", // 1 ^8
"brown", // 2 ^7
"fox", // 3 ^6
"jumped", // 4 ^5
"over", // 5 ^4
"the", // 6 ^3
"lazy", // 7 ^2
"dog" // 8 ^1
}; // 9 (words.Length) ^0
Console.WriteLine($"The last word is {words[^1]}"); // dog
var allWords = words[..]; // All values, equivalent to words[0..^0].
var firstPhrase = words[..4]; // From start to words[4], excluding words[4]
var lastPhrase = words[6..]; // From words[6] to end
// Declare a range variable
Range phrase = 1..4;
var text = words[phrase];
?? and ??=
?? Null-coalescing operator: Available since C# 6. Returns the value of its left-hand operand if it is not null; otherwise, it evaluates the right-hand operand and returns its result. If the left-hand operand evaluates to non-null, the right-hand operand is not evaluated.
??= Null-coalescing assignment operator: Available since C# 8. Assigns the value of the right-hand operand to the left-hand operand only if the left-hand operand evaluates to null. Otherwise, the right-hand operand is not evaluated. The left-hand operand of ??= must be a variable, property, or indexer element.
// ?? null-coalescing operator
Console.WriteLine($"name is {OutputName(null)}");
static string OutputName(string name) => name ?? "some one";
// Using ??= assignment operator
variable ??= expression;
// Old way
if (variable is null)
{
variable = expression;
}
Top-Level Statements
C# 9 introduced top-level statements, eliminating unnecessary ceremony from applications. Only one file in the application can use top-level statements. Top-level statements make the main program more readable by reducing boilerplate: namespace, class Program, and static void Main().
When creating a console project with VS, selecting .NET 5 or later uses top-level statements.
// Default generated content by VS2022 for a .NET 6.0 console project
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
global using
C# 10 added the global using directive. When the global keyword appears before a using directive, that using applies to the entire project, reducing the number of using directives in each file. global using directives can appear at the beginning of any source code file but must be placed before non-global using directives.
The global modifier can be used with the static modifier and can also be applied to using alias directives. In both cases, the scope of the directive is all files in the current compilation.
global using System;
global using static System.Console; // Global static import
global using Env = System.Environment; // Global alias
File-Scoped Namespaces
C# 10 introduced file-scoped namespaces. You can declare a namespace as a statement followed by a semicolon, without braces. A code file usually contains only one namespace, which simplifies code and eliminates one level of nesting. A file-scoped namespace cannot declare nested namespaces or a second file-scoped namespace, and it must appear before any type declarations. All types in that file belong to that namespace.
using System;
namespace SampleFileScopedNamespace;
class SampleClass { }
interface ISampleInterface { }
struct SampleStruct { }
enum SampleEnum { a, b }
delegate void SampleDelegate(int i);
with Expressions
Starting from C# 9, with expressions produce a copy of its operand with specified properties and fields modified. Values that are not modified remain the same as the original. For reference type members, only the reference to the member instance is copied; both the copy produced by with and the original have access to the same reference type instance.
In C# 9, the left operand of a with expression must be of a record type. C# 10 improves this: the left operand of a with expression can also be a struct type.
public record NamedPoint(string Name, int X, int Y);
var p1 = new NamedPoint("A", 0, 0);
var p2 = p1 with { Name = "B", X = 5 };