02 July 2025

C# 11 and 12 Best Features: Write Less, Do More

In this article, we’ll take a look at some of the new features introduced in C# 11 and 12 that help simplify your code and make development smoother. These updates may not be revolutionary, but they’re practical and designed to save time by reducing unnecessary complexity. We’ll see how small changes can lead to cleaner, more efficient solutions in everyday coding tasks.

Raw String Literals

Building strings with complex content has historically presented a challenge in C#. Developers frequently contend with escaping special characters, newlines, and quotation marks, leading to verbose and often unreadable code. This process becomes particularly cumbersome when dealing with formats like JSON, XML, or regular expressions embedded directly in source files.

C# 11 introduced raw string literals to directly address this pain point. This feature allows strings to span multiple lines and contain virtually any character, including embedded quotes and backslashes, without the need for escape sequences. A raw string literal begins and ends with at least three double-quote characters (""").

Before C# 11:

string oldJson = "{\r\n  \"name\": \"Alice\",\r\n  \"age\": 30\r\n}";
Console.WriteLine(oldJson);

With C# 11:

string newJson = """
  {
    "name": "Alice",
    "age": 30
  }
  """;
Console.WriteLine(newJson);

Any whitespace preceding the closing quotes defines the minimum indentation for the string, which the compiler removes from the final output. Raw string literals drastically improve string readability and reduces the likelihood of syntax errors.

List Patterns

Pattern matching in C# has evolved significantly, with C# 11 introducing list patterns to enable sequence matching within arrays or lists. This enhancement allows developers to inspect collection structure and content concisely and expressively.

Previously, validating collection structure required manual checks on length and individual indices, leading to verbose and less maintainable code. List patterns address this by supporting sub-patterns such as constant, type, property, and relational patterns. Key features include the discard pattern (_) for matching any single element and the range pattern (..) for matching any sequence of zero or more elements.

Before C# 11:

int[] numbers = { 1, 2, 3 };

if (numbers != null && numbers.Length == 3 &&
    numbers[0] == 1 && numbers[1] == 2 && numbers[2] == 3)
{
    Console.WriteLine("Array contains exactly 1, 2, 3.");
}

if (numbers != null && numbers.Length >= 2 && numbers[1] == 2)
{
    Console.WriteLine("Array has 2 as its second element.");
}

With C# 11:

int[] numbers = { 1, 2, 3 };

if (numbers is [1, 2, 3])
{
    Console.WriteLine("Array contains exactly 1, 2, 3.");
}

if (numbers is [_, 2, ..])
{
    Console.WriteLine("Array has 2 as its second element.");
}

List patterns streamline sequence validation into a compact, readable form, significantly reducing the lines of code needed for such operations.

Required Members

Object initialization can sometimes lead to an undesirable state where essential properties or fields remain unassigned. Traditionally, developers enforce mandatory initialization through constructors that accept all required parameters or by adding defensive checks within methods.

C# 11 introduces the required modifier for properties and fields, a compile-time enforcement mechanism. When a member is marked required, the compiler ensures it receives a value during object creation, either through a constructor or an object initializer. This guarantees that instances of a type are always in a valid, fully initialized state, preventing common bugs related to missing data.

Before C# 11:

public class OldPerson
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    public void DisplayName() => Console.WriteLine($"Name: {FirstName} {LastName}");
}

// Usage:
var person = new OldPerson(); // No compile-time error, but creates potentially invalid object
person.DisplayName();

With C# 11:

public class NewPerson
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }

    public void DisplayName() => Console.WriteLine($"Name: {FirstName} {LastName}");
}

// Usage:
// var person = new NewPerson(); // Compile error - missing required properties
// var person = new NewPerson { FirstName = "John" }; // Compile error - LastName missing
var person = new NewPerson { FirstName = "Jane", LastName = "Doe" }; // OK
person.DisplayName();

Required members eliminate runtime surprises by enforcing initialization at compile time, reducing the need for manual checks. This feature enhances code reliability with less defensive coding, allowing developers to focus on functionality over validation.

Primary Constructors

C# 12 introduces primary constructors for all classes and structs, expanding a feature once exclusive to record types. This allows constructor parameters to be declared directly in the type definition, automatically scoping them as fields or properties across the entire class. Unlike traditional constructors, this approach skips explicit field declarations and manual assignments.

The key problem solved here is the repetitive boilerplate in object initialization. Previously, developers had to define private fields and map constructor arguments to them explicitly, inflating code size unnecessarily. Primary constructors simplify this, embedding initialization logic directly into the type signature.

Before C# 12:

public class OldProduct
{
    private readonly int _productId;
    private readonly string _productName;

    public OldProduct(int productId, string productName)
    {
        _productId = productId;
        _productName = productName;
    }

    public string PrintDetails() => $"Product ID: {_productId}, Name: {_productName}";
}

// Usage:
OldProduct oldProd = new OldProduct(101, "Laptop");
oldProd.PrintDetails();

With C# 12:

public class NewProduct(int productId, string productName)
{
    public string PrintDetails() => $"Product ID: {productId}, Name: {productName}";
}

// Usage:
NewProduct newProd = new NewProduct(102, "Keyboard");
newProd.PrintDetails();

Primary constructors make the definition of data-centric types incredibly succinct. They improve readability by placing the essential construction parameters immediately next to the type name, making the class's or struct's dependencies clear at a glance.

Collection Expressions

Initializing collections in C# has historically involved various syntaxes depending on the collection type, such as new List<T> { ... } for lists or new T[] { ... } for arrays. Combining or merging existing collections into a new one often required iterative loops or LINQ methods like Concat(), adding overhead and verbosity.

C# 12 introduces collection expressions, a unified and concise syntax for creating and initializing a wide range of collection types. Using a simple [...] syntax, developers can create arrays, lists, Span<T>, and other collection-like types. The new spread element (..) allows inlining elements from existing collections directly into a new collection expression, eliminating the need for manual concatenation.

Before C# 12:

// Initializing different collections
int[] initialNumbers = new int[] { 1, 2, 3 };
List<int> moreNumbers = new List<int> { 4, 5 };

// Combining collections
List<int> allNumbers = new List<int>();
allNumbers.AddRange(initialNumbers);
allNumbers.AddRange(moreNumbers);
allNumbers.Add(6);
allNumbers.Add(7);

Console.WriteLine(string.Join(", ", allNumbers));

With C# 12:

// Initializing different collections
int[] initialNumbers = [1, 2, 3];
List<int> moreNumbers = [4, 5];

// Combining collections using the spread operator
List<int> allNumbers = [..initialNumbers, ..moreNumbers, 6, 7];

Console.WriteLine(string.Join(", ", allNumbers));

Collection expressions cut down the verbosity of initializing and combining collections, delivering a cleaner and more intuitive syntax. This efficiency accelerates coding and enhances readability, supporting the principle of achieving greater impact with fewer lines.

Default Lambda Parameters

Lambda expressions, a cornerstone of functional programming in C#, have historically lacked the ability to define default values for their parameters. If a lambda needed to handle optional arguments or provide fallback values, developers had to resort to conditional logic within the lambda body or define multiple overloads, even though lambdas do not directly support overloads.

C# 12 closes this gap by allowing default values for parameters in lambda expressions. The syntax and behavior mirror those of method or local function parameters, providing a more fluent and concise way to define flexible lambda functions.

Before C# 12:

// Lambda without default parameters.
// If a default was desired for 'y', often a wrapper or conditional logic was needed:
Func<int, int, int> addOld = (x, y) => x + y;

Func<int, int> addWithDefaultOld = x => addOld(x, 10); // A common workaround

Console.WriteLine(addOld(5, 3));
Console.WriteLine(addWithDefaultOld(5));

With C# 12:

// Lambda with default parameters
Func<int, int, int> addNew = (x, y = 10) => x + y;

Console.WriteLine(addNew(5, 3)); // y is 3
Console.WriteLine(addNew(5));    // y defaults to 10

The introduction of default parameters for lambdas significantly enhances their flexibility and expressiveness. It reduces the need for redundant lambda definitions or internal conditional logic.

Conclusion

C# 11 and 12 deliver a compelling set of features that live up to the promise of “Write Less, Do More”. From C# 11's raw string literals and list patterns to C# 12's primary constructors and collection expressions, these advancements address real frustrations in daily coding. They strip away unnecessary syntax, elevate readability, and enforce safer patterns, directly enhancing workflows in software development and code conversion projects. Each innovation—be it enforcing required members or simplifying collection setups—reduces keystrokes while maximizing clarity and minimizing error risks.

Related Articles