← Back to Articles         
C# 15.0 – Important Features Explained

C# 15.0 – Important Features Explained

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

C# 15 is part of the .NET 11 development cycle, with its final release expected in November 2026.

We will cover three topics:

  1. Collection Expression Arguments – Pass configuration arguments while creating collections.

  2. Union Types – Represent multiple possible types in a type-safe manner.

  3. Enhanced params Support – 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:

  • 10 in the constructor specifies the initial capacity.

  • 10, 20, 30 are 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:

  1. HashSet<T> automatically removes duplicate elements.

  2. StringComparer.OrdinalIgnoreCase performs case-insensitive comparisons.

  3. "John" and "JOHN" are considered equal.

  4. "David" and "DAVID" are considered equal.

  5. 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>, or ReadOnlySpan<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.

Microsoft Learn
+1

 

 

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:

  • object can hold values of unrelated types.

  • The compiler does not restrict the result to Success or Failure.

  • 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

union

class, abstract class

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

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.

Microsoft Learn
+1

 

 

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.

Microsoft Learn
+1

 

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:

  1. The method accepts multiple integer arguments.

  2. The compiler can package the arguments into an array.

  3. 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

params int[]

params IEnumerable<int>

params ReadOnlySpan<int>

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.

Microsoft Learn
+1

 

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.

Microsoft Learn
+1

 

 

5. Final Comparison – C# 15 Features

Feature

Collection Expression Arguments

Union Types

Expanded params

Introduced

C# 15 preview

C# 15 preview

C# 13

Main purpose

Configure collection construction

Represent alternative types

Accept variable arguments

Main syntax

with(...)

union

params

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

  1. What are Collection Expression Arguments in C# 15?

  2. What is the purpose of with(...) in a collection expression?

  3. What is the difference between collection expressions in C# 12 and C# 15?

  4. What are Union Types, and why were they introduced?

  5. How do Union Types differ from interfaces, inheritance, and object?

  6. What is exhaustive pattern matching?

  7. How would you use Union Types for ASP.NET Core Web API results?

  8. What is the difference between params int[] and params ReadOnlySpan<int>?

  9. How can params ReadOnlySpan<T> reduce memory allocations?

  10. What restrictions apply to the params keyword?

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.

 

← Back to Articles