← Back to Articles         
C# 12.0 – Important Features Explained

C# 12.0 – Important Features Explained

Published on 12 Oct 2026     12 min read C#
C# 12 - Features

C# 12.0 was released in November 2023 alongside .NET 8. It introduced improvements in constructor syntax, collection initialization, lambda expressions, and memory-efficient data structures.

The four important features are:

  1. Primary Constructors – Simplify constructor declarations and dependency injection.

  2. Collection Expressions ([]) – Provide a concise syntax for initializing collections.

  3. Default Lambda Parameters – Allow optional parameters in lambda expressions.

  4. Inline Arrays – Enable fixed-size arrays stored directly within a value type.

 

1. Primary Constructors

What is it?

Primary Constructors were introduced for classes and structs in C# 12. They allow constructor parameters to be declared directly in the class or struct declaration.

Previously, developers had to write a separate constructor to initialize fields or properties.

With Primary Constructors, constructor parameters become available throughout the class body.

Why is it used?

  • Reduces constructor boilerplate.

  • Simplifies dependency injection.

  • Makes small classes easier to read.

  • Avoids repetitive constructor assignments.

Before C# 12

public class EmployeeService
{
    private readonly ILogger<EmployeeService> _logger;

    public EmployeeService(
        ILogger<EmployeeService> logger)
    {
        _logger = logger;
    }

    public void GetEmployees()
    {
        _logger.LogInformation("Fetching employees");
    }
}

With C# 12

public class EmployeeService(
    ILogger<EmployeeService> logger)
{
    public void GetEmployees()
    {
        logger.LogInformation("Fetching employees");
    }
}

The separate constructor and explicit field are no longer required in this example.

Example Program

public class Employee(int id, string name)
{
    public int Id { get; } = id;

    public string Name { get; } = name;

    public void Display()
    {
        Console.WriteLine($"Id: {Id}");
        Console.WriteLine($"Name: {Name}");
    }
}

class Program
{
    static void Main()
    {
        Employee emp = new Employee(101, "John");

        emp.Display();
    }
}

Output

Id: 101
Name: John

Program Explanation

Step 1: Declare the Primary Constructor

public class Employee(int id, string name)
  • id and name are constructor parameters.

  • They are supplied when creating an object.

  • No separate constructor body is needed.

Step 2: Initialize properties

public int Id { get; } = id;
public string Name { get; } = name;

The constructor parameters initialize the properties.

Step 3: Create the object

Employee emp = new Employee(101, "John");

The values are passed to the Primary Constructor.

How does it work?

Employee(101, "John")
         |
         v
Primary Constructor Parameters
         |
         +-- id = 101
         |
         +-- name = "John"
         |
         v
Property Initialization
         |
         +-- Id = 101
         |
         +-- Name = "John"
         |
         v
Employee Object Created
         |
         v
Display()

Real-World Example: Dependency Injection in ASP.NET Core

public class EmployeeService(
    IEmployeeRepository repository,
    ILogger<EmployeeService> logger)
{
    public async Task<Employee?> GetByIdAsync(int id)
    {
        logger.LogInformation(
            "Fetching employee {EmployeeId}", id);

        return await repository.GetByIdAsync(id);
    }
}

Explanation:

  • repository is injected through the Primary Constructor.

  • logger is injected through the same constructor.

  • Both parameters can be accessed inside instance methods.

  • ASP.NET Core dependency injection supports this syntax.

Primary Constructor vs Traditional Constructor

Traditional Constructor

Primary Constructor

Declared inside the class

Declared with the class

Requires an explicit constructor body

No separate constructor body required

Often requires field assignments

Can access parameters directly

More boilerplate

More concise

Useful for complex initialization

Useful for straightforward initialization and DI

Important: Primary Constructor Parameters Are Not Properties

Consider:

public class Employee(int id, string name)
{
    public void Display()
    {
        Console.WriteLine(name);
    }
}

Here, name is a constructor parameter, not a public property.

This is invalid:

Employee emp = new Employee(101, "John");

// Compilation error
Console.WriteLine(emp.name);

To expose the value publicly, declare a property.

Also, primary constructor parameters are not automatically readonly. If they are captured for later use, the compiler may generate hidden storage.

Key Points

  • Introduced for regular classes and structs in C# 12.

  • Constructor parameters are declared with the type.

  • Reduces boilerplate code.

  • Particularly useful with dependency injection.

  • Parameters do not automatically become properties or fields.

  • Unlike record positional parameters, regular class primary constructor parameters do not generate public properties.

  • Additional constructors must ultimately delegate to the primary constructor using this(...).

  • Be careful about duplicate storage when assigning parameters to fields and also accessing the parameters directly.

Interview Tip: Primary Constructors simplify dependency injection in ASP.NET Core services and controllers, but they do not automatically create public properties.

 

2. Collection Expressions ([])

What is it?

Collection Expressions were introduced in C# 12 to provide a simplified and consistent syntax for creating collections.

They use square brackets [] instead of traditional collection initialization syntax.

Why is it used?

Before C# 12, different collection types required different initialization syntax.

int[] numbers = new int[] { 10, 20, 30 };

List<int> values = new List<int> { 10, 20, 30 };

With C# 12:

int[] numbers = [10, 20, 30];

List<int> values = [10, 20, 30];

This makes collection initialization more consistent.

Example Program

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        int[] numbers = [10, 20, 30];

        List<string> names = ["John", "David", "Peter"];

        int[] empty = [];

        Console.WriteLine("Numbers:");

        foreach (int number in numbers)
        {
            Console.WriteLine(number);
        }

        Console.WriteLine("Names:");

        foreach (string name in names)
        {
            Console.WriteLine(name);
        }

        Console.WriteLine($"Empty Count: {empty.Length}");
    }
}

Output

Numbers:
10
20
30
Names:
John
David
Peter
Empty Count: 0

Program Explanation

Step 1: Create an array

int[] numbers = [10, 20, 30];

Creates an integer array containing three values.

Step 2: Create a List

List<string> names = ["John", "David", "Peter"];

Creates a List<string> with three names.

Step 3: Create an empty collection

int[] empty = [];

Creates an empty integer array.

How does it work?

Collection Expression
      |
      v
[10, 20, 30]
      |
      v
Target Collection Type
      |
      +-- int[]
      |
      +-- List<int>
      |
      +-- Span<int>
      |
      +-- ReadOnlySpan<int>
      |
      v
Collection Initialized

The target type determines how the collection expression is interpreted.

Spread Element (..)

C# 12 also introduced the spread element syntax .. inside collection expressions.

It allows elements from an existing collection to be included in a new collection.

Example

int[] first = [10, 20];

int[] second = [30, 40];

int[] combined = [..first, ..second, 50];

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

Output

10, 20, 30, 40, 50

Explanation

first  = [10, 20]
second = [30, 40]
           |
           v
[..first, ..second, 50]
           |
           v
[10, 20, 30, 40, 50]

The .. syntax expands the elements of each collection into the new collection.

Collection Expressions vs List Patterns

Both features use square brackets, but their purposes differ.

Collection Expressions

List Patterns

Introduced in C# 12

Introduced in C# 11

Creates collections

Matches collection structure

int[] x = [1, 2, 3];

x is [1, 2, 3]

.. spreads elements

.. matches zero or more elements

Important Limitation

Collection expressions generally require a target type.

This is invalid:

var numbers = [10, 20, 30];

The compiler cannot infer a standalone collection type from this expression.

Use:

int[] numbers = [10, 20, 30];

Key Points

  • Introduced in C# 12.

  • Uses square brackets for initialization.

  • Supports arrays, lists, spans, and other compatible collection types.

  • .. spreads elements from another collection.

  • Requires a suitable target type.

  • Improves readability and reduces repetitive syntax.

  • The compiler may choose efficient collection creation strategies depending on the target type.

Interview Tip: Collection Expressions provide a common initialization syntax for different collection types, while List Patterns are used for matching existing collections.

 

3. Default Lambda Parameters

What is it?

Default Lambda Parameters were introduced in C# 12 to allow lambda expressions to declare optional parameters with default values.

Previously, lambda expressions did not support default parameter values.

With C# 12, we can define defaults directly in lambda parameter declarations.

Why is it used?

  • Simplifies lambda expressions.

  • Supports optional arguments.

  • Reduces the need for multiple delegates or wrapper methods.

  • Useful for small reusable functions.

Example Program

class Program
{
    static void Main()
    {
        var calculate = (int amount, int tax = 10) =>
        {
            return amount + tax;
        };

        Console.WriteLine(calculate(100));

        Console.WriteLine(calculate(100, 20));
    }
}

Output

110
120

Program Explanation

Step 1: Define lambda expression

var calculate = (int amount, int tax = 10) =>
{
    return amount + tax;
};

Here:

  • amount is mandatory.

  • tax is optional.

  • Default value of tax is 10.

Step 2: Call without optional parameter

calculate(100);

The compiler uses tax = 10.

Result:

100 + 10 = 110

Step 3: Call with optional parameter

calculate(100, 20);

The explicitly supplied value overrides the default.

Result:

100 + 20 = 120

How does it work?

Lambda Expression
(int amount, int tax = 10)
            |
            v
       Method Call
            |
     +------+------+
     |             |
     v             v
calculate(100)  calculate(100, 20)
     |             |
     v             v
tax = 10        tax = 20
     |             |
     v             v
Result = 110    Result = 120

Another Example: Greeting

var greet = (string name, string message = "Welcome") =>
    $"{message}, {name}!";

Console.WriteLine(greet("John"));

Console.WriteLine(greet("John", "Good Morning"));

Output

Welcome, John!
Good Morning, John!

Lambda vs Normal Method

Normal Method

Lambda Expression

Named method

Often anonymous

Supports optional parameters

Supports optional parameters from C# 12

Can contain complex logic

Commonly used for concise logic

Defined as a class/struct member or local function

Can be assigned to delegates

Important Limitation

Optional parameter values are compile-time metadata associated with the callable signature.

For example:

var calculate = (int amount, int tax = 10) =>
    amount + tax;

The compiler can infer a delegate type that supports the default argument.

However:

Func<int, int, int> calculate =
    (int amount, int tax = 10) => amount + tax;

This is a valid lambda assignment, but the following is invalid:

calculate(100); // Compilation error

The variable has type Func<int, int, int>, whose Invoke method requires two arguments.

You must call:

calculate(100, 10);

Key Points

  • Introduced in C# 12.

  • Allows default values in lambda parameters.

  • Parameters with defaults can be omitted when the callable signature supports them.

  • Explicit arguments override defaults.

  • Works well with naturally inferred delegate types.

  • A predefined delegate such as Func<T1,T2,TResult> does not gain optional parameters automatically.

  • Default parameter values must follow normal C# optional parameter rules.

Interview Tip: Default Lambda Parameters allow optional arguments in lambda expressions, but whether an argument can be omitted depends on the delegate's callable signature.

 

4. Inline Arrays

What is it?

Inline Arrays were introduced in C# 12 to define fixed-size sequences of elements stored directly within a struct.

They use the [InlineArray(length)] attribute.

Unlike a normal array, an inline array does not require a separate array object for its elements.

Why is it used?

Inline Arrays are useful when:

  • A fixed number of elements is required.

  • Memory allocation should be minimized.

  • Data locality is important.

  • Developing high-performance or low-level applications.

Normal Array

int[] numbers = new int[5];

A normal array is a reference type and is typically allocated on the managed heap.

Inline Array

using System.Runtime.CompilerServices;

[InlineArray(5)]
public struct NumberBuffer
{
    private int _element0;
}

This defines a value type containing storage for five integers.

Example Program

using System;
using System.Runtime.CompilerServices;

[InlineArray(5)]
public struct NumberBuffer
{
    private int _element0;
}

class Program
{
    static void Main()
    {
        NumberBuffer numbers = new();

        numbers[0] = 10;
        numbers[1] = 20;
        numbers[2] = 30;
        numbers[3] = 40;
        numbers[4] = 50;

        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine(numbers[i]);
        }
    }
}

Output

10
20
30
40
50

Program Explanation

Step 1: Declare Inline Array

[InlineArray(5)]
public struct NumberBuffer
{
    private int _element0;
}
  • [InlineArray(5)] specifies the number of elements.

  • NumberBuffer is a struct.

  • _element0 determines the element type (int).

  • The compiler and runtime support indexing across the five elements.

Step 2: Create Inline Array

NumberBuffer numbers = new();

Creates a value of type NumberBuffer.

Step 3: Assign values

numbers[0] = 10;
numbers[1] = 20;

Assigns values to the first and second elements.

Step 4: Read values

Console.WriteLine(numbers[0]);

Reads the first element.

How does it work?

NumberBuffer Struct
        |
        v
[InlineArray(5)]
        |
        v
Fixed-size Integer Storage
        |
        +----------+
        | Index 0  | 10
        +----------+
        | Index 1  | 20
        +----------+
        | Index 2  | 30
        +----------+
        | Index 3  | 40
        +----------+
        | Index 4  | 50
        +----------+
        |
        v
Stored Inline Within the Struct

Inline Array vs Normal Array

Normal Array

Inline Array

Reference type

Value type (struct)

Elements stored in a separate array object

Elements stored inline in the struct

Length established at runtime

Length specified by an attribute

Supports normal array APIs

Has more limited direct APIs

Common for general application code

Intended for specialized performance scenarios

Separate array allocation normally required

No separate array allocation required for elements

Important Memory Concept

Inline arrays do not always live on the stack.

Their storage location depends on where the containing value resides.

Inline Array as Local Value
          |
          v
May be stored in stack-based storage
or optimized by the JIT

Inline Array as Field of Class
          |
          v
Stored inside the class object
on the managed heap

Inline Array as Field of Struct
          |
          v
Stored inside the containing struct

Therefore, inline arrays should not be described simply as stack-allocated arrays.

Key Points

  • Introduced in C# 12, supported by .NET 8.

  • Uses [InlineArray(length)].

  • Must be declared on a struct with exactly one instance field.

  • Provides fixed-size inline storage.

  • Avoids allocating a separate array object for the elements.

  • Useful for high-performance programming.

  • Unlike normal arrays, inline arrays do not provide the same built-in array interface and API surface.

  • Bounds checking and supported indexing behavior depend on the compiler-generated operations.

  • Usually unnecessary for standard business application development.

Interview Tip: Inline Arrays are specialized value types that store fixed-size sequences directly within a struct. They can reduce allocations but are primarily intended for performance-sensitive code.

 

C# 12.0 – Quick Revision

Feature

Main purpose

Important syntax

Primary Constructors

Simplify constructor declarations

class Employee(int id)

Collection Expressions

Simplify collection initialization

int[] x = [1, 2, 3];

Default Lambda Parameters

Support optional lambda arguments

(int x = 10) => x * 2

Inline Arrays

Fixed-size inline storage

[InlineArray(5)]

Most Important Interview Questions

  1. What are Primary Constructors, and how do they differ from traditional constructors?

  2. How do Primary Constructors simplify dependency injection in ASP.NET Core?

  3. What are Collection Expressions, and how do they differ from collection initializers?

  4. What is the difference between Collection Expressions in C# 12 and List Patterns in C# 11?

  5. What are Default Lambda Parameters, and how do they work?

  6. What are Inline Arrays, and how do they differ from normal arrays?

  7. Do Inline Arrays always allocate memory on the stack?

For a Senior .NET Developer or Technical Lead interview, Primary Constructors and Collection Expressions are the highest-priority C# 12 features because they are directly applicable to modern ASP.NET Core and .NET 8 development. Default Lambda Parameters are useful to understand, while Inline Arrays are more relevant to low-level and performance-critical programming.

← Back to Articles