C# 15 is part of the .NET 11 development cycle, with its final release expected in November 2026.
We will cover three topics:
-
Collection Expression Arguments – Pass configuration arguments while creating collections.
-
Union Types – Represent multiple possible types in a type-safe manner.
-
Enhanced
paramsSupport – Accept a variable number of arguments using different collection types.
For each topic, we'll examine the purpose, syntax, practical C# examples, internal behavior, advantages, limitations, and interview key points.
Important: C# 15 is still in preview as of October 2026. The first two features use preview syntax that may change. Expanded params collections were actually introduced in C# 13, so the third topic will explain that feature and its relationship to C# 15.
1. Collection Expression Arguments
What is it?
Collection Expression Arguments is a new C# 15 feature that allows developers to pass constructor or factory-method arguments directly inside a collection expression using with(...).
Before understanding this feature, we need to understand collection expressions introduced in C# 12.
Before C# 15
Traditional approach:
List<int> numbers = new List<int>(10)
{
10, 20, 30
};
Here:
-
10in the constructor specifies the initial capacity. -
10, 20, 30are the collection elements. -
The collection initially reserves space for 10 elements, although it contains only 3.
C# 12 collection expression:
List<int> numbers = [10, 20, 30];
This is shorter, but there is no syntax here to explicitly pass a constructor argument such as capacity.
C# 15 – New Syntax
List<int> numbers = [with(capacity: 10), 10, 20, 30];
The with(capacity: 10) expression passes the capacity argument to the collection constructor.
Complete Example Program
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<int> numbers =
[with(capacity: 10), 10, 20, 30];
Console.WriteLine($"Count: {numbers.Count}");
Console.WriteLine($"Capacity: {numbers.Capacity}");
foreach (int number in numbers)
{
Console.WriteLine(number);
}
}
}
Output:
Count: 3
Capacity: 10
10
20
30
How does it work internally?
Conceptually, the compiler can translate the collection expression into code similar to:
List<int> numbers = new List<int>(10);
numbers.Add(10);
numbers.Add(20);
numbers.Add(30);
This is a conceptual explanation, not a guarantee of the exact compiler-generated code.
Collection Expression
|
v
[with(capacity: 10), 10, 20, 30]
|
v
Constructor Selection
|
v
new List<int>(10)
|
v
Add Collection Elements
|
+-- Add(10)
+-- Add(20)
+-- Add(30)
|
v
List<int>
Count = 3
Capacity = 10
Example 2: Using a HashSet with a Comparer
Consider a requirement where duplicate names should be removed regardless of uppercase or lowercase characters.
HashSet<string> names =
[
with(StringComparer.OrdinalIgnoreCase),
"John",
"JOHN",
"David",
"DAVID"
];
foreach (string name in names)
{
Console.WriteLine(name);
}
Output:
John
David
Explanation:
-
HashSet<T>automatically removes duplicate elements. -
StringComparer.OrdinalIgnoreCaseperforms case-insensitive comparisons. -
"John"and"JOHN"are considered equal. -
"David"and"DAVID"are considered equal. -
The resulting collection contains only two elements.
Important Rules
-
with(...)must appear as the first element of the collection expression. -
Arguments are passed to a suitable constructor or collection factory method.
-
Constructor overload resolution determines which constructor is selected.
-
The feature supports useful arguments such as capacity and comparers.
-
It is not supported for array,
Span<T>, orReadOnlySpan<T>targets. -
It does not modify an existing collection; it configures the collection being created.
Advantages
-
Cleaner collection initialization.
-
Less boilerplate code.
-
Allows collection-specific configuration.
-
Improves readability.
-
Can improve performance when an appropriate capacity is specified.
Interview Key Points
Q: What is the main difference between C# 12 and C# 15 collection expressions?
C# 12 introduced collection expressions using []. C# 15 extends them with with(...) to pass constructor or factory arguments.
Q: Does with(capacity: 10) create 10 elements?
No. It specifies the initial capacity. The collection count depends on the number of elements actually added.
Q: Can we use multiple with(...) elements?
No. A collection expression supports one with(...) element, and it must be first.
Reference: Microsoft – Collection Expressions.
2. Union Types
What is it?
A Union Type allows a variable to represent one value from a predefined set of types.
For example, an API operation may return either:
-
Success– The operation completed successfully. -
Failure– The operation failed.
Traditionally, developers might use a common base class, an interface, or a custom result wrapper.
C# 15 introduces a more direct approach using the union keyword.
Why do we need Union Types?
Consider an ASP.NET Core Web API that saves employee information.
The operation can produce different results:
Save Employee
|
v
EmployeeService.Save()
|
+-------------------+
| |
v v
Success Failure
| |
v v
Employee Saved Error Message
| |
+---------+---------+
|
v
ApiResult Union
Instead of returning an object and manually checking every possible type, we can define an explicit set of valid result types.
Before C# 15 – Using object
public record Success(string Message);
public record Failure(string Error);
object SaveEmployee(bool isValid)
{
if (isValid)
return new Success("Employee saved");
return new Failure("Invalid employee");
}
Usage:
object result = SaveEmployee(true);
if (result is Success success)
{
Console.WriteLine(success.Message);
}
else if (result is Failure failure)
{
Console.WriteLine(failure.Error);
}
Problems:
-
objectcan hold values of unrelated types. -
The compiler does not restrict the result to
SuccessorFailure. -
The developer must manually check the possible types.
-
Adding an unexpected result type can lead to missing handling.
C# 15 – Using Union Types
Syntax:
public union ApiResult(Success, Failure);
This declares a union that can represent either Success or Failure.
Complete Example Program
using System;
public record Success(string Message);
public record Failure(string Error);
public union ApiResult(Success, Failure);
class Program
{
static ApiResult SaveEmployee(bool isValid)
{
if (isValid)
{
return new Success("Employee saved successfully");
}
return new Failure("Employee validation failed");
}
static void Main()
{
ApiResult result = SaveEmployee(true);
string message = result switch
{
Success success => success.Message,
Failure failure => failure.Error
};
Console.WriteLine(message);
}
}
Output:
Employee saved successfully
Step-by-Step Explanation
Step 1: Define result types
public record Success(string Message);
public record Failure(string Error);
Each record represents one possible outcome.
Step 2: Define the union
public union ApiResult(Success, Failure);
This tells the compiler that ApiResult supports two case types.
Step 3: Return a result
return new Success("Employee saved successfully");
The compiler allows an implicit conversion from Success to ApiResult.
Step 4: Handle the result
string message = result switch
{
Success success => success.Message,
Failure failure => failure.Error
};
Pattern matching identifies the case and executes the appropriate expression.
How does it work?
ApiResult
|
+-- Success
| |
| +-- Message
|
+-- Failure
|
+-- Error
At runtime:
ApiResult result
|
v
Contains Success
|
v
Switch Pattern Matching
|
v
Success Case Executed
|
v
"Employee saved successfully"
The union represents one case at a time, not both simultaneously.
Example 2: Banking Transaction
Consider a fund-transfer operation.
Possible results:
public record TransferSuccess(string TransactionId);
public record InsufficientFunds(decimal Balance);
public record AccountNotFound(int AccountId);
public union TransferResult(
TransferSuccess,
InsufficientFunds,
AccountNotFound
);
Service method:
static TransferResult TransferMoney(
decimal balance,
decimal amount)
{
if (balance < amount)
{
return new InsufficientFunds(balance);
}
return new TransferSuccess("TXN1001");
}
Usage:
TransferResult result = TransferMoney(5000, 7000);
string message = result switch
{
TransferSuccess success =>
$"Transfer completed: {success.TransactionId}",
InsufficientFunds failure =>
$"Insufficient balance: {failure.Balance}",
AccountNotFound account =>
$"Account not found: {account.AccountId}"
};
Console.WriteLine(message);
Output:
Insufficient balance: 5000
Exhaustive Pattern Matching
One important benefit of union types is exhaustiveness checking.
Suppose a union supports three cases:
public union TransferResult(
TransferSuccess,
InsufficientFunds,
AccountNotFound
);
A switch expression should handle all three cases.
TransferResult
|
+-- TransferSuccess --> Handled
|
+-- InsufficientFunds --> Handled
|
+-- AccountNotFound --> Handled
|
v
All Cases Covered
The compiler understands the union's declared case types and can check whether the switch covers them. This is particularly useful when additional cases are introduced later.
Union Types vs Inheritance
|
Feature |
Union Types |
Inheritance |
|---|---|---|
|
Purpose |
Represent alternative types |
Establish an is-a relationship |
|
Declaration |
|
|
|
Case set |
Explicitly defined |
May be open to derived types |
|
Pattern matching |
Supports exhaustiveness checking |
Generally requires additional handling |
|
Main use |
Results, states, outcomes |
Shared behavior and polymorphism |
Union Types vs object
|
|
Union Type |
|---|---|
|
Can hold any compatible .NET value |
Restricts values to defined cases |
|
Requires runtime type checking |
Compiler understands case types |
|
No inherent exhaustive matching |
Supports exhaustive matching |
|
Less specific API contract |
More expressive API contract |
Advantages
-
Improves type safety.
-
Makes method return contracts clearer.
-
Reduces reliance on
object. -
Supports pattern matching.
-
Helps model success, failure, and other business outcomes.
-
Useful in banking, payment processing, validation, and Web API services.
Important Limitations
-
Union types are a C# 15 preview feature.
-
Some capabilities described in the language proposal are not yet implemented.
-
Exhaustiveness checking depends on the compiler's knowledge of the declared cases.
-
Union types do not automatically replace ASP.NET Core HTTP response handling.
Interview Key Points
Q: What is a union type in C# 15?
A union type represents a value that can be one of a predefined set of types.
Q: What is the main advantage of union types?
They provide type-safe representation of alternative results and support exhaustive pattern matching.
Q: Where would you use union types in a real-world application?
In service methods returning success, validation failure, insufficient balance, or other business outcomes.
Q: Can a union contain multiple case values simultaneously?
No. A union value represents one of its defined cases at a time.
Reference: Microsoft – Union Types Tutorial.
3. Enhanced params Support
What is it?
The params keyword allows a method to accept a variable number of arguments without requiring the caller to create an array explicitly.
Before C# 13, params was restricted to single-dimensional arrays.
Starting with C# 13, it supports additional collection types, including:
-
Span<T> -
ReadOnlySpan<T> -
IEnumerable<T> -
IReadOnlyCollection<T> -
IList<T> -
List<T>and other suitable concrete collection types
This functionality is available when using C# 15, but it is not a new C# 15 feature.
Before C# 13 – Array-Based params
Example:
using System;
class Program
{
static void PrintNumbers(params int[] numbers)
{
foreach (int number in numbers)
{
Console.WriteLine(number);
}
}
static void Main()
{
PrintNumbers(10, 20, 30);
}
}
Output:
10
20
30
Explanation:
-
The method accepts multiple integer arguments.
-
The compiler can package the arguments into an array.
-
The method iterates over the array.
Conceptually:
PrintNumbers(10, 20, 30)
|
v
Compiler Creates Array
|
v
int[] { 10, 20, 30 }
|
v
PrintNumbers(int[] numbers)
|
v
10
20
30
The disadvantage is that an array allocation may be required at the call site.
C# 13+ – Collection-Based params
Example using IEnumerable<T>:
using System;
using System.Collections.Generic;
class Program
{
static void PrintNumbers(
params IEnumerable<int> numbers)
{
foreach (int number in numbers)
{
Console.WriteLine(number);
}
}
static void Main()
{
PrintNumbers(10, 20, 30);
}
}
Output:
10
20
30
Explanation:
The caller still passes individual integers, but the parameter is declared as IEnumerable<int> instead of int[].
The compiler supplies a suitable collection to the method.
Example 2: Using ReadOnlySpan<T>
ReadOnlySpan<T> represents a contiguous region of memory that can be read without allowing modification through that span.
It is useful in performance-sensitive code.
Complete Example:
using System;
class Program
{
static int CalculateTotal(
params ReadOnlySpan<int> numbers)
{
int total = 0;
foreach (int number in numbers)
{
total += number;
}
return total;
}
static void Main()
{
int total = CalculateTotal(10, 20, 30, 40);
Console.WriteLine($"Total: {total}");
}
}
Output:
Total: 100
How does it work?
CalculateTotal(10, 20, 30, 40)
|
v
Compiler Prepares Arguments
|
v
ReadOnlySpan<int>
|
+-- 10
+-- 20
+-- 30
+-- 40
|
v
CalculateTotal()
|
v
Sum = 100
Unlike array-based params, span-based params can avoid an allocation for the argument collection at the call site.
However, this is not guaranteed in every situation. The actual behavior depends on the arguments and compiler-generated code.
Example 3: Real-World Banking Scenario
Suppose we want to calculate the total amount of several transactions.
using System;
class Program
{
static decimal CalculateTransactions(
params ReadOnlySpan<decimal> amounts)
{
decimal total = 0;
foreach (decimal amount in amounts)
{
total += amount;
}
return total;
}
static void Main()
{
decimal total = CalculateTransactions(
1500m,
2500m,
1000m
);
Console.WriteLine($"Total Amount: {total}");
}
}
Output:
Total Amount: 5000
Explanation:
-
The method accepts multiple transaction amounts.
-
ReadOnlySpan<decimal>provides read-only access to the supplied values. -
The method calculates their sum.
-
This approach can reduce temporary allocations in frequently called methods.
Comparison: Array vs IEnumerable<T> vs ReadOnlySpan<T>
|
Feature |
|
|
|
|---|---|---|---|
|
Supported since |
Earlier C# versions |
C# 13 |
C# 13 |
|
Variable arguments |
Yes |
Yes |
Yes |
|
Can enumerate |
Yes |
Yes |
Yes |
|
Index access |
Yes |
Not guaranteed |
Yes |
|
Allocation-free potential |
Limited |
Implementation-dependent |
Yes |
|
Best suited for |
General-purpose APIs |
Collection abstraction |
Performance-sensitive APIs |
Important Rules
Rule 1: params must be the last parameter.
Valid:
void Print(string title, params int[] numbers)
{
}
Invalid:
// Compilation error
void Print(params int[] numbers, string title)
{
}
Rule 2: Only one params parameter is allowed.
Invalid:
// Compilation error
void Print(
params int[] numbers,
params string[] names)
{
}
Rule 3: Arguments are optional.
void Print(params int[] numbers)
{
Console.WriteLine(numbers.Length);
}
Print(); // 0
Print(10); // 1
Print(10, 20); // 2
Rule 4: Only recognized collection types are supported.
You cannot use params with arbitrary types.
For example:
// Invalid
void Print(params int number)
{
}
Performance Considerations
Consider two methods:
static int SumArray(params int[] numbers)
{
int total = 0;
foreach (int number in numbers)
total += number;
return total;
}
static int SumSpan(params ReadOnlySpan<int> numbers)
{
int total = 0;
foreach (int number in numbers)
total += number;
return total;
}
Both methods perform the same calculation.
The difference is how the arguments can be represented.
Array-Based params
|
v
Temporary Array May Be Allocated
|
v
Process Elements
|
v
Garbage Collector Eventually Reclaims Array
ReadOnlySpan-Based params
|
v
Compiler May Avoid Temporary Heap Allocation
|
v
Process Elements
|
v
Less GC Pressure in Suitable Scenarios
Important: In C# 13 and later, overload resolution generally prefers an applicable params ReadOnlySpan<T> overload over a corresponding params T[] overload for expanded-form calls. This can affect existing code, particularly expression trees.
Advantages
-
Supports flexible collection parameter types.
-
Can reduce temporary memory allocations.
-
Improves API design.
-
Supports high-performance applications.
-
Retains convenient method-call syntax.
Interview Key Points
Q: What is the purpose of params?
It allows a method to accept a variable number of arguments.
Q: What changed in C# 13?
The params modifier was expanded beyond arrays to support recognized collection types such as ReadOnlySpan<T> and IEnumerable<T>.
Q: Why use params ReadOnlySpan<T>?
It can reduce temporary heap allocations while providing efficient, read-only access to a sequence of values.
Q: Can we declare multiple params parameters?
No. A method can have only one params parameter, and it must be last.
Q: Does params IEnumerable<T> always improve performance?
No. The compiler may need to create backing storage. Performance depends on the collection type and how the method is called.
4. How to Enable C# 15 Preview Features
To test the C# 15 features, install a compatible .NET 11 preview SDK.
In your .csproj file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
You can check the installed SDK versions with:
dotnet --list-sdks
Create a console project with a compatible preview SDK:
dotnet new console -n CSharp15Demo -f net11.0
Add the preview language setting to the project file, then run:
cd CSharp15Demo
dotnet run
The available syntax depends on the installed SDK preview version. Some union features require .NET 11 Preview 5 or later.
5. Final Comparison – C# 15 Features
|
Feature |
Collection Expression Arguments |
Union Types |
Expanded |
|---|---|---|---|
|
Introduced |
C# 15 preview |
C# 15 preview |
C# 13 |
|
Main purpose |
Configure collection construction |
Represent alternative types |
Accept variable arguments |
|
Main syntax |
|
|
|
|
Primary benefit |
Concise initialization |
Type safety |
API flexibility |
|
Performance benefit |
Capacity optimization possible |
Implementation-dependent |
Allocation reduction possible |
|
Real-world use |
Lists, sets, custom collections |
API and business results |
Utility and performance-sensitive methods |
6. Most Important Interview Questions
-
What are Collection Expression Arguments in C# 15?
-
What is the purpose of
with(...)in a collection expression? -
What is the difference between collection expressions in C# 12 and C# 15?
-
What are Union Types, and why were they introduced?
-
How do Union Types differ from interfaces, inheritance, and
object? -
What is exhaustive pattern matching?
-
How would you use Union Types for ASP.NET Core Web API results?
-
What is the difference between
params int[]andparams ReadOnlySpan<int>? -
How can
params ReadOnlySpan<T>reduce memory allocations? -
What restrictions apply to the
paramskeyword?
For a .NET Technical Lead interview, prioritize Union Types for API and domain modeling, Collection Expression Arguments for modern syntax, and params ReadOnlySpan<T> for performance optimization.