C# Operators and Initialization Syntax by Version

A version-by-version guide to C# operators and initialization syntax, with guidance for .NET Framework compatibility and language-version constraints.

Overview

This article covers the C# operators and initialization syntax sugar introduced across versions from C# 1.0 through C# 12.
It explains which features are available in .NET Framework, which ones depend only on LangVersion, and which ones require additional BCL types or attributes.
The goal is to help with implementation decisions when newer syntax cannot be used in a legacy build environment.


Prerequisites / Environment


Problem

In C# development targeting .NET Framework, a project may be compiled with a language version earlier than C# 8.0.
In that case, syntax such as ??= cannot be used even if the target framework itself is otherwise capable of running the code.

private List<int> _numbers;

public void AddNumber(int val)
{
    _numbers ??= new List<int>(); // Compilation error in environments that compile below C# 8.0
    _numbers.Add(val);
}

This becomes an issue when newer syntax is used in source code but the compiler is configured with an older LangVersion.


Cause / Background

C# language version support is independent of the target framework.
Feature availability depends mainly on two factors:

  1. The compiler and LangVersion.
  2. Runtime-side requirements such as BCL types or attributes.

As a result, even when targeting .NET Framework, language features such as ??= and ! can be used if the build environment supports C# 8.0.

The following table summarizes the operators and syntax covered in this article and the C# version in which they were introduced.
Plotted over time, they line up as follows.

A timeline from C# 1.0 to 12.0 with the operators and syntax added in each version. Only ^, .. and required are shaded to mark that they need a BCL type or attribute.
The C# version that introduced each construct. The shaded chips need more than a higher LangVersion: they depend on a BCL type (System.Index / System.Range) or attribute. ??= sits at C# 8.0.
Operator / Syntax C# Version .NET Version .NET Framework Support
?? (null-coalescing) C# 2.0 .NET Framework 2.0 βœ… Supported from 2.0 onward
as (type cast) C# 1.0 .NET Framework 1.0 βœ… All versions
is (type check) C# 1.0 .NET Framework 1.0 βœ… All versions
=> (lambda) C# 3.0 .NET Framework 3.5 βœ… Language feature only (†1)
=> (expression-bodied members) C# 6.0 .NET Framework 4.6 βœ… Language feature only (†1)
?. ?[] (null-conditional) C# 6.0 .NET Framework 4.6 βœ… Language feature only (†1)
nameof C# 6.0 .NET Framework 4.6 βœ… Language feature only (†1)
is pattern matching C# 7.0 .NET Framework 4.7 βœ… Language feature only (†1)
??= (null-coalescing assignment) C# 8.0 .NET Core 3.0 / .NET 5 βœ… Language feature only (†1)
! (null-forgiving) C# 8.0 .NET Core 3.0 / .NET 5 βœ… Language feature only (†1)
^ (index from end) C# 8.0 .NET Core 3.0 / .NET 5 ⚠️ Requires BCL type (†2)
.. (range) C# 8.0 .NET Core 3.0 / .NET 5 ⚠️ Requires BCL type (†2)
with expression C# 9.0 .NET 5 βœ… Language feature only (†1)
Target-typed new C# 9.0 .NET 5 βœ… Language feature only (†1)
required property C# 11.0 .NET 7 ⚠️ Requires BCL attribute (†3)
Collection expressions C# 12.0 .NET 8 βœ… Language feature only (†1)
Primary constructors C# 12.0 .NET 8 βœ… Language feature only (†1)

Solution

When a compile error occurs because of new C# syntax, there are two main approaches.

Option 1: Raise LangVersion or update the build environment

The C# version used by a project can be controlled by specifying <LangVersion> in the project file (.csproj).

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net481</TargetFramework>
    <LangVersion>8.0</LangVersion>  <!-- Enables ??= and ! -->
  </PropertyGroup>
</Project>

Updating Visual Studio or the .NET SDK can also make a newer compiler available.

Option 2: Rewrite to older syntax

When the build environment cannot be changed, or when required BCL types are missing, equivalent older syntax can be used instead.

// When ??= cannot be used
_numbers = _numbers ?? new List<int>();

// When ^ cannot be used because System.Index is unavailable
int last = array[array.Length - 1];

// When .. cannot be used because System.Range is unavailable
int[] sliced = array.Skip(1).Take(3).ToArray();

These examples preserve the same meaning while using older syntax. The LINQ example requires using System.Linq;.


Implementation

1. Null-safe operators

?. and ?[] (null-conditional operators) β€” C# 6.0 and later

These operators access members or elements only when the object or collection is not null.
If the target is null, evaluation stops and null is returned, which removes the need for a separate null check.

string title = GetTitle();
int? length = title?.Length; // length becomes null if title is null

List<string> items = GetItems();
string firstItem = items?[0]; // firstItem becomes null if items is null

?. and ?[] can be combined in a chain.
If any step evaluates to null, the entire chain returns null.

?? (null-coalescing operator) β€” C# 2.0 and later

This operator returns the left-hand value when it is not null.
If the left-hand value is null, the right-hand value is returned instead.
It is used to provide a default value for nullable expressions.

string typedName = GetName();
string displayName = typedName ?? "Anonymous"; // "Anonymous" when typedName is null

?? has been supported since .NET Framework 2.0 and can be used regardless of version within that runtime range.

??= (null-coalescing assignment operator) β€” C# 8.0 and later

This operator assigns the right-hand value only when the left-hand variable is null.
It is often used for lazy initialization.

private List<int> _numbers;

public void AddNumber(int val)
{
    _numbers ??= new List<int>(); // Create the instance only when _numbers is null
    _numbers.Add(val);
}

When the build environment compiles below C# 8.0, this syntax cannot be used.
An equivalent rewrite is shown below.

_numbers = _numbers ?? new List<int>();

This form expresses the same intent, although ??= is more concise in environments that support it.

! (null-forgiving operator) β€” C# 8.0 and later

This operator tells the C# static analyzer that a value is definitely not null at that point in the code.
It suppresses nullable warnings at compile time only and does not change runtime behavior.

string? rawInput = GetValidatedInput();
// Assume validation guarantees that the value is not null here.
string solidInput = rawInput!;

Using ! removes a null check from the compiler’s analysis.
If null is actually passed, a runtime exception can still occur.
For that reason, usage should be kept to a minimum.


2. Index and range

^ (index from end operator) β€” C# 8.0 and later

This operator creates a System.Index value that represents a position counted from the end of a collection.
^1 refers to the last element (Length - 1), and ^0 refers to the position just past the last element (Length).

int[] digits = new[] { 10, 20, 30, 40 };
int last = digits[^1];          // 40, equivalent to digits[digits.Length - 1]
int secondFromLast = digits[^2]; // 30

On .NET Framework, System.Index is not provided by default.
Without an additional reference or polyfill, the ^ operator cannot be used.
An explicit index calculation such as array[array.Length - 1] is the alternative.

.. (range operator) β€” C# 8.0 and later

This operator creates a System.Range value from a start index and an end index, and it enables intuitive slicing of arrays and strings.
The start index is included, and the end index is excluded.

int[] dataset = new[] { 0, 1, 2, 3, 4, 5 };
int[] sliced = dataset[1..4]; // [1, 2, 3]

// Omitting start or end
int[] continuous = dataset[2..];  // [2, 3, 4, 5]
int[] allButLast = dataset[..^1]; // [0, 1, 2, 3, 4]

The .. operator requires System.Range.
On .NET Framework, it cannot be used without an additional reference or polyfill.
A common alternative is array.Skip(start).Take(count).ToArray() with LINQ.


3. Type operators

is and as (type check and type cast operators)

is checks whether an object is compatible with a specific type.
From C# 7.0 onward, it can be combined with pattern matching to declare and assign a variable when the type matches.

as attempts a type conversion and returns the cast object on success.
If the conversion fails, it returns null instead of throwing an exception.

object element = "Hello WPF";

// is pattern matching (C# 7.0 and later)
if (element is string message)
{
    Console.WriteLine(message.Length); // Treated as string within this block
}

// as operator
var stream = element as System.IO.Stream; // stream becomes null because conversion fails

A direct cast such as (Type)obj throws InvalidCastException when the conversion fails.
By contrast, as returns null, which makes it suitable when type compatibility is uncertain.


4. Data manipulation and other operators

=> (lambda operator / expression-bodied members) β€” C# 3.0 / C# 6.0 and later

C# 3.0 introduced => as the syntax for lambda expressions.
C# 6.0 later extended it to expression-bodied members, which allow properties and methods to be written in a single expression.

public class Rectangle
{
    private readonly double _width;
    private readonly double _height;

    public Rectangle(double width, double height)
    {
        _width = width;
        _height = height;
    }

    // Expression-bodied property (C# 6.0)
    public double Area => _width * _height;

    // Expression-bodied method (C# 6.0)
    public void PrintArea() => Console.WriteLine($"Area: {Area}");
}

Expression-bodied members are effective when the logic can be expressed as a single expression.
They reduce boilerplate and improve readability in small members.

nameof β€” C# 6.0 and later

This operator returns the identifier name of a variable, type, property, or method as a string at compile time.
It avoids hard-coded string literals, which improves refactor safety and reduces typos.

public void UpdateText(string? newText)
{
    if (newText == null)
    {
        throw new ArgumentNullException(nameof(newText));
    }
}

The result of nameof is a compile-time constant.
For that reason, it can also be used in case labels and attribute arguments.

with expression β€” C# 9.0 and later

This expression creates a new copy instance based on an immutable object such as a record or struct, while changing only selected properties.
The original object remains unchanged.

public record WindowSettings(string Title, double Width, double Height);

var defaultSettings = new WindowSettings("Main", 800, 600);
var tallSettings = defaultSettings with { Height = 1000 };

with is a C# 9.0 language feature and can be used on .NET Framework when LangVersion is set to C# 9.0 or later.
However, when using record or init accessors, .NET Framework also requires a definition or polyfill for System.Runtime.CompilerServices.IsExternalInit.


5. Initialization syntax sugar

Target-typed new β€” C# 9.0 and later

When the instantiation type can be inferred from the target type on the left-hand side or from a method parameter, the type name after new can be omitted.

public class Example
{
    public void Run()
    {
        // Traditional form
        Dictionary<string, List<string>> map1 = new Dictionary<string, List<string>>();

        // Target-typed new
        Dictionary<string, List<string>> map2 = new();

        // Applied to method arguments
        RegisterNumbers(new() { 1, 2, 3 });
    }

    private void RegisterNumbers(List<int> numbers) { }
}

The type can be omitted only when it is unambiguous from the left-hand side or from the parameter type.
var cannot be combined with this syntax, because the right-hand side type would no longer be inferable.
For that reason, var map = new(); is a compile error.

Collection expressions β€” C# 12.0 and later

This syntax provides a unified [...] notation for initializing arrays, List<T>, Span<T>, and other custom collections.

int[] row = [1, 2, 3];                     // Array
List<string> tags = ["C#", "WPF", ".NET"]; // List<T>
ReadOnlySpan<byte> data = [0x00, 0x01];    // Span<T>

Inside a collection expression, the .. spread operator can be used to flatten and combine another collection’s elements.

int[] left = [1, 2];
int[] right = [5, 6];

int[] result = [.. left, 3, 4, .. right]; // Produces [1, 2, 3, 4, 5, 6]

Collection expressions were introduced in C# 12.0 and require a compiler and SDK that support C# 12.0 or later.

required property β€” C# 11.0 and later

A property marked with required must be initialized when the object is created through an object initializer.
This prevents missed initialization without adding extra constructor parameters.

public class AppTheme
{
    public required string ThemeName { get; init; } // Required at initialization time
    public string Author { get; init; } = "Unknown"; // Optional, because a default value exists
}

// Compiles successfully
var lightTheme = new AppTheme { ThemeName = "Light Mode" };

// Compile error because ThemeName is not specified
// var invalidTheme = new AppTheme { Author = "s-iguchi" };

required was introduced in C# 11.0.
Starting with .NET 7, System.Runtime.CompilerServices.RequiredMemberAttribute is provided by the platform.
In environments such as .NET Framework where the attribute is not available, it must be defined manually or supplied through an additional package.

Primary constructor β€” C# 12.0 and later

Starting with C# 12, class and struct types can define constructor parameters directly after the type name.
This removes the need for a constructor body or boilerplate field assignments.

public class LogWriter(string logFilePath, LogLevel minimumLevel)
{
    // Parameters can be referenced directly from within the class.
    public void WriteLog(string message, LogLevel level)
    {
        if (level >= minimumLevel)
        {
            System.IO.File.AppendAllText(logFilePath, $"[{level}] {message}\n");
        }
    }
}

The values passed as parameters, such as logFilePath and minimumLevel, can be referenced directly from any member of the class.
Primary constructors were introduced in C# 12.0 and require a compiler and SDK that support C# 12.0 or later.


Notes


Alternatives / Comparison

The following table compares the main approaches for handling compile errors caused by new C# syntax.

Approach Pros Cons Best suited for
Raise LangVersion New syntax can be used directly. Code remains concise. Requires updates to the build environment such as Visual Studio or the SDK. Projects where compiler settings can be changed.
Update the build environment Provides the latest language features and tooling support. May have a wider impact on existing projects. New development or environments that can be updated.
Rewrite to older syntax Works without changing the environment. Code becomes more verbose and newer features cannot be used. Legacy environments where updates are not allowed.
Add BCL type polyfills (^ / ..) Enables System.Index / System.Range on .NET Framework. Requires extra maintenance for polyfills. Projects that need these operators but must stay on .NET Framework.

Summary

C# operators and initialization syntax have been added gradually across language versions.
Whether a feature can be used depends mainly on the compiler configuration (LangVersion) and on any runtime-side types or attributes the feature requires.

The following selection criteria are practical guidelines.

The appropriate syntax should be selected after confirming the combination of compiler version (LangVersion) and target framework.