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:
-
Primary Constructors – Simplify constructor declarations and dependency injection.
-
Collection Expressions (
[]) – Provide a concise syntax for initializing collections. -
Default Lambda Parameters – Allow optional parameters in lambda expressions.
-
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)
-
idandnameare 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:
-
repositoryis injected through the Primary Constructor. -
loggeris 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 |
|
|
|
|
|
|
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:
-
amountis mandatory. -
taxis optional. -
Default value of
taxis10.
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. -
NumberBufferis a struct. -
_element0determines 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 |
|
|
Collection Expressions |
Simplify collection initialization |
|
|
Default Lambda Parameters |
Support optional lambda arguments |
|
|
Inline Arrays |
Fixed-size inline storage |
|
Most Important Interview Questions
-
What are Primary Constructors, and how do they differ from traditional constructors?
-
How do Primary Constructors simplify dependency injection in ASP.NET Core?
-
What are Collection Expressions, and how do they differ from collection initializers?
-
What is the difference between Collection Expressions in C# 12 and List Patterns in C# 11?
-
What are Default Lambda Parameters, and how do they work?
-
What are Inline Arrays, and how do they differ from normal arrays?
-
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.