A new version of C# is coming soon, so we're continuing our annual tradition of reviewing its new features. The Microsoft team has prepared several highly anticipated and exciting changes for this release. Let's take a look at them.
Union types
We'll start with the most straightforward change, union types. They enable developers to combine types without relying on inheritance or interfaces where it's unnecessary or unavailable. A union is a structure that contains the complete set of supported types.
public union TestUnion(int, Version, IEnumerable<string>);
In the code above, union is the keyword, while int, Version, and IEnumerable<string> are the set of supported types.
The union types come in handy when traditional inheritance isn't an option, as it requires the types to have something in common. However, there may be cases where inheritance is impossible or makes little architectural sense. We might need to handle a specific set of types, including primitives. Normally, we'd have to specify a variable of the object type as input and then cast it to the required types within the method, including logic for handling anything unsupported. That's error-prone. Using union in this situation would solve these problems, making the code safer and easier to read.
Given the nature of the new structure, when we use union, the compiler already knows the complete list of types it can contain. So, the default branch is no longer necessary. The change protects us from a switch that misses some types while new subclasses keep falling into the default section, which can cause unpredictable program behavior.
Let's look at a more specific example. Before this update, we'd have had to write something like this to define a method for handling API responses:
public abstract record ApiResult;
public record ErrorData(string Message);
public record Success(string Data) : ApiResult;
public record ValidationError(ErrorData Data) : ApiResult;
public string Process(ApiResult result)
{
return result switch
{
Success s => s.Data,
ValidationError s => s.Data.Message,
_ => "Unknown result"
};
}
The ApiResult abstract class, from which specific responses inherit, is declared. So, if a new subclass appears and the developer fails to handle it, executions falls into the default branch. With a union, it works differently:
public record ErrorData(string Message);
public record Success(string Data);
public record ValidationError(ErrorData Data);
public union ApiResult(Success, ValidationError)
{
public string GetData()
{
return this switch
{
Success s => s.Data,
ValidationError s => s.Data.Message
};
}
}
As you can see, there's no need for a parent class just to group the types together. The behavior is also more predictable because the compiler knows at compile time exactly which types union can contain. So, if a switch doesn't handle every type in the union, the compiler issues a warning.
Moreover, the union gives developers an easier way to express patterns such as monads. It also enables implementing custom methods and properties within union, so working with all its types doesn't require writing switch.
Closed hierarchies
This change is somewhat related to union in that it also lets the compiler know exactly how many types it'll work with. It does so by preventing inheritance from a class outside the assembly where it was declared. This behavior resembles a mix of internal and sealed: it takes the scope from internal and the restriction on inheritance from sealed. This can be useful, for example, in libraries where we want to restrict inheritance from certain classes without blocking it within our own assembly. Here's an example declaration:
public closed record ClosedRecordClass;
The closed keyword marks the class as closed to inheritance. As a result, attempting to inherit from such a class in another assembly causes a compilation error. Note that, like union, a closed hierarchy supports an exhaustive switch, which enables omitting the default branch.
A few important details are worth keeping in mind. Firstly, a class marked as closed is always abstract. This also helps enable an exhaustive switch. Secondly, descendants don't automatically become closed. If we need to hide them from other assemblies, we can mark them with the internal access modifier. Thirdly, only classes (class and record class) can be closed to inheritance from another assembly. Structs don't support inheritance, while interfaces are specifically designed to be inherited from. So, adding the keyword to a struct or an interface will result in a compilation error.
Labeled break and continue
A small but quite important addition: most of us have probably run into situations where we needed to interrupt the execution of an external structure or skip to its next iteration. The classic approach would have looked something like this:
public enum WorkItemType
{
Process,
StopAll
}
public class WorkItem
{
// ....
public WorkItemType Type { get; set; }
}
public class OldLabeledBreak
{
public void PerformAction(IEnumerable<WorkItem> workList)
{
bool stopProcessing = false;
foreach (var item in workList) // #1
{
switch (item.Type) // #2
{
case WorkItemType.Process:
// ....
break; // break #2
case WorkItemType.StopAll:
stopProcessing = true;
break; // break #2
}
if (stopProcessing)
break; // break #1
}
// ....
}
}
In other words, we need an additional flag so that the outer loop can exit after the inner switch finishes executing. Of course, we could use goto, but it can make the code harder to follow because the label code execution jumps to can appear anywhere. Labeled break and continue simplify this. Let's see what this method would look like now.
public void PerformAction(IEnumerable<WorkItem> workList)
{
outer: foreach (var item in workList) // #1
{
switch (item.Type) // #2
{
case WorkItemType.Process:
// ....
break; // break #2
case WorkItemType.StopAll:
break outer; // break #1
}
}
// ....
}
As you can see, the syntax is similar to goto, while the code becomes more readable. This lets us avoid creating extra flags and checks, which can slightly reduce memory usage and also make the code more expressive. Labels can be applied with the following constructs: for, foreach, while, do, and switch.
Extension indexers
This change enables developers to define how collection elements can be accessed by index using extension indexers. For example, it's possible to implement element access with Range for IReadOnlyList. Before C# 15, we could implement this as follows:
public class OldExtensionIndexers
{
public void Process()
{
IReadOnlyList<string> readOnlyList = ["val1", "val2", "val3"];
var elem = readOnlyList.Slice(..2);
}
}
public static class OldReadOnlyListExtensions
{
public static IReadOnlyList<T> Slice<T>(this IReadOnlyList<T> list,
Range range)
{
var (offset, length) = range.GetOffsetAndLength(list.Count);
var result = new List<T>(length);
for (int i = 0; i < length; i++)
result.Add(list[offset + i]);
return result;
}
}
While a regular extension method already solves this problem, C# 15 introduces the option to create extension indexers. So, we'll get access to elements using the familiar brackets. Let's look at how this would work in our example:
public class NewExtensionIndexers
{
public void Process()
{
IReadOnlyList<string> readOnlyList = ["val1", "val2", "val3"];
var elem = readOnlyList[..2];
}
}
public static class NewReadOnlyListExtensions
{
extension<T>(IReadOnlyList<T> list)
{
public IReadOnlyList<T> this[Range range]
{
get
{
var (offset, length) = range.GetOffsetAndLength(list.Count);
return list.Skip(offset).Take(length).ToList();
}
}
}
}
It's worth noting that if a type has its own indexer, it takes precedence over an indexer declared as an extension. The official order of precedence is as follows: explicit type indexers, implicit type indexers, explicit extension indexers, and implicit extension indexers.
Collection expression arguments
The next feature will help shorten code and make collection initialization more convenient by passing the required arguments directly to the constructor. For example, we can pass capacity and comparer. In C# 14 and earlier, we would need to write something like this:
public void ProcessList()
{
List<string> loginList = ["login1", "login2"];
List<string> newLoginList = new List<string>(loginList.Count);
newLoginList.AddRange(loginList);
}
public void ProcessHashSet()
{
HashSet<string> hashSet = new HashSet<string>
(StringComparer.OrdinalIgnoreCase);
hashSet.Add("value 1");
hashSet.Add("value 2");
}
The initializer supports only one option because List has just three constructors: an empty one, one that takes capacity, and one that takes a collection. Let's look at the new possible implementation:
public void ProcessList()
{
List<string> loginList = ["login1", "login2"];
List<string> newLoginList = [with(loginList.Count), ..loginList];
// newLoginList.AddRange(loginList);
}
public void ProcessHashSet()
{
HashSet<string> hashSet = [with(StringComparer.OrdinalIgnoreCase),
"value 1", "value 2"];
// hashSet.Add("value 1");
// hashSet.Add("value 2");
}
The code looks cleaner, right? Your IDE will probably even suggest this syntax and collapse everything into a single line. However, note that with must always come first; otherwise, the compiler will issue an error.
Memory safety
The final change involves reevaluating what C# considers unsafe. The language team aims to flag only code that actually accesses unmanaged memory as unsafe. In other words, using pointers will no longer automatically make code unsafe. Before C# 15, unsafe code was marked like this:
public unsafe class OldMemorySafety
{
public int ReadMemory(byte* ptr)
{
return *ptr;
}
public int Process()
{
byte b = 42;
byte* newPtr = &b;
return ReadMemory(newPtr);
}
}
Any use of pointers used to require the unsafe keyword. To better understand the change, let's take a look at what the code will look like in the new version:
public class NewMemorySafety
{
public int ReadMemory(byte* ptr)
{
unsafe
{
return *ptr;
}
}
public int Process()
{
byte b = 42;
byte* newPtr = &b;
return ReadMemory(newPtr);
}
}
We no longer need to mark the class as unsafe. Instead, the keyword goes only where it's needed. In this case, dereferencing the pointer is unsafe, so it should be marked as such.
The safe keyword has also been added. It should be used when a block contains unsafe operations that are safe for the caller's context, so callers don't require the unsafe block. In other words, use safe when the compiler can't verify safety on its own. Examples include extern methods and fields in structs with explicit memory layout ([StructLayout(LayoutKind.Explicit)]).
It's also worth mentioning stackalloc. Its use is now considered unsafe when all of the following apply: it's converted to Span<T> or ReadOnlySpan<T>, it has no initializer, and it's used where SkipLocalsInitAttribute is present.
The new rules introduced by this change should be enabled separately. If you'd like to learn more, follow this link for the official instructions.
Conclusion
This year's language release has brought developers a number of fairly important features. Among them are useful additions such as union types and closed hierarchies, while several other changes simplify common coding patterns, making development little smoother once again. Some of these features may not become as popular, but that doesn't make them any less useful to certain groups of developers. Which of these changes did you find most useful? Share your thoughts in the comments!
You can read the C# 15 documentation via this link. Don't forget to check out our overviews of previous versions, here's the list:

Top comments (0)