.NET 11.0 Preview 3 brings Union Types and enhances File-based Apps

The third preview version of .NET 11.0 makes the long-awaited Union Types in C# practically usable, because the Visual Studio IDE now also cooperates.

listen Print view
Traffic sign with inscription .NET

(Image: Pincasso / Shutterstock.com)

8 min. read
By
  • Dr. Holger Schwichtenberg
Contents

Microsoft has released .NET version 11.0 Preview 3 along with Visual Studio 2026 Insiders version 11709.129. .NET 11.0 includes language version 15.0 of the C# programming language.

The Dotnet Doctor – Holger Schwichtenberg
Der Dotnet-Doktor – Holger Schwichtenberg

Dr. Holger Schwichtenberg is the technical director of the expert network www.IT-Visions.de, which supports numerous medium-sized and large companies with consulting and training services as well as software development, drawing on the expertise of 53 renowned experts. Thanks to his appearances at numerous national and international conferences, as well as more than 90 specialist books and over 1,500 specialist articles, Holger Schwichtenberg is one of the best-known experts for .NET and web technologies in Germany.

A Discriminated Union (often also called Tagged Union, Algebraic Data Type, or Sum Type) is a data type that can contain exactly one of several possible types, with it being clear at any time which type is currently contained. Many modern languages, such as F#, Rust, Swift, or TypeScript, support this concept, while others like Kotlin allow its emulation. In C#, developers have previously had to rely on the base type System.Object, inheritance, interfaces, pattern matching, or implementations based on generic types such as the OneOf library.

In C# 15.0, developers create a Union Type using the new C# keyword union, specifying a name and one or more types, for example:

public union UnionName(Typ1, Typ2, Typ3);

The specified types do not need to have any commonalities: neither a base class nor an interface needs to be shared. The keyword null cannot be specified as a type. However, Nullable Value Types (e.g., int?) and Nullable Reference Types (Person?) are allowed.

Videos by heise

A variable of type UnionName can then be assigned instances of Typ1, Typ2, and Typ3, as well as null if applicable. However, any other object type assignment will be prevented.

Using the is operator or pattern matching, you can query which concrete type the union type variable contains. While pattern matching automatically casts to the target type, without pattern matching you have to use the Value property to get the concrete type. However, Value returns the type System.Object, so a cast is required again.

As an example, consider a typical use case for Discriminated Unions: a business logic operation can signal various error cases in addition to a concrete result object with strings, numbers, or exception objects. When using switch expressions, the compiler warns if the block does not query all cases with the warning CS8509: "The switch expression does not handle all possible values of its input type (it is not exhaustive)."

Union Types have the potential to simplify parts of the .NET application frameworks, for example, with WebAPI operations using Typed Results. Currently, this is not yet possible, but it is on the roadmap for ASP.NET WebAPIs and ASP.NET Core SignalR as well as Blazor for implementation by the end of 2026.

The following code shows a C# 15.0 Union Type for differentiated return types of a business logic method:

#nullable enable
 
namespace NET11_Console.CS15;
 
public class Person
{
 public int ID { get; set; }
 public string Name { get; set; }
 public string Website { get; set; }
 
 public override string ToString()
 {
  return $"Person: #{ID} Name: {Name} Website: {Website}";
 }
}
 
public union PersonOperationResult(Person?, string, int?, Exception);
 
class BL
{
 public PersonOperationResult GetPerson(int ID)
 {
  try
  {
   if (ID <= 0) return "UngĂĽltige Person-ID";
   if (ID == 123) return new Person() { ID = ID, Name = "Dr. Holger Schwichtenberg", Website = "www.IT-Visions.de" };
   if (ID == 0815) throw new ApplicationException("Anwendungsfehler");
   return ID; // Person nicht gefunden
  }
  catch (Exception ex)
  {
   return ex;
  }
 
 }
}
 
class UnionTypeDemo
{
 public void Run()
 {
  CUI.Demo(nameof(UnionTypeDemo));
  BL BL = new();
 
  CUI.H1("\nLade Person mit ID 123");
  PersonOperationResult result1 = BL.GetPerson(123);
  PrintResult(result1);
 
  CUI.H1("\nTest mit Null-Wert");
  PersonOperationResult result2 = null;
  PrintResult(result2);
 
  CUI.H1("\nLade Person mit ID 0 (Fehlerfall)");
  PersonOperationResult result3 = BL.GetPerson(-1);
  PrintResult(result3);
 
  CUI.H1("\nLade Person mit ID 101 (nicht gefunden)");
  PersonOperationResult result4 = BL.GetPerson(101);
  PrintResult(result4);
 
  CUI.H1("\nLade Person mit ID 0815 (Anwendungsfehler)");
  PersonOperationResult result5 = BL.GetPerson(0815);
  PrintResult(result5);
 
  // Nicht erlaubt
  //PersonOperationResult result = new FileInfo(@"c:\temp\Datei.xy");
 }
 
 private static void PrintResult(PersonOperationResult result)
 {
  if (result is int)
  {
   CUI.Error($"Person #{result.Value} nicht gefunden");
  }
  else if (result is string)
  {
   CUI.Error($"Fehler: {result.Value}");
  }
  else if (result is Exception)
  {
   CUI.Error($"Fehler: {(result.Value as Exception).Message}");
  }
  else if (result is Person)
  {
   CUI.Print(result.Value);
  }
  else if (result is null)
  {
   CUI.Error("Null-Wert");
  }
 }
 
 private static void PrintResult_PatternMatching(PersonOperationResult result)
 {
  if (result is int i)
  {
   CUI.Error($"Person #{i} nicht gefunden");
  }
  else if (result is string s)
  {
   CUI.Error($"Fehler: {s}");
  }
  else if (result is Exception ex)
  {
   CUI.Error($"Fehler: {ex.Message}");
  }
  else if (result is Person p)
  {
   CUI.Print(p);
  }
 }
 
 private static void PrintResult_Switch(PersonOperationResult result)
 {
  Console.WriteLine(result switch
  {
   Person p => $"Person: {p.Name}",
   string s => $"Fehler: {s}",
   Exception ex => $"Fehler: {ex.Message}",
   int i => $"Person #{i} nicht gefunden",
   null => "Null-Wert"
  });
 }
}

The example demonstrates various cases when loading a "Person" using an ID (Fig. 1).

Initial support for Union Types was available in Preview 2 of .NET 11.0 in March. However, Microsoft forgot to mention this feature in the blog post. In the Release Notes for .NET 11.0 Preview 2, the C# link led nowhere. In early April, there was a blog post that followed up, stating that Union Types were already possible in Preview 2, but without editor support. The editor now works for Union Types in Visual Studio version 2026 Insiders 11709.129.

However, even in Preview 3, a piece of code is still missing in the base class library. In addition to the tag <LangVersion>preview</LangVersion> in the project file, developers must also include the content of the following listing with the implementation of the annotation [Union] via class UnionAttribute in every project that wants to use Union Types:

namespace System.Runtime.CompilerServices
{
 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct,
     AllowMultiple = false)]
 public sealed class UnionAttribute : Attribute;
 
 public interface IUnion
 {
  object? Value { get; }
 }
}

The background is that the C# 15.0 compiler automatically annotates all Union Types with [Union]. This can be seen when decompiling the Union Type with ILSpy:

// NET11_Console, Version=11.3.0.0, Culture=neutral, PublicKeyToken=null
// NET11_Console.CS15.PersonOperationResult
using System;
using System.Runtime.CompilerServices;
using NET11_Console.CS15;

[Union]
public struct PersonOperationResult : IUnion
{
    public object? Value { get; }

    [CompilerGenerated]
    public PersonOperationResult(Person? value)
    {
        Value = value;
    }

    [CompilerGenerated]
    public PersonOperationResult(string value)
    {
        Value = value;
    }

    [CompilerGenerated]
    public PersonOperationResult(int value)
    {
        Value = value;
    }

    [CompilerGenerated]
    public PersonOperationResult(Exception value)
    {
        Value = value;
    }
}

In .NET 11.0 Preview 1, Microsoft introduced compression with Zstandard as an alternative to Deflate, GZip, and Brotli. In Preview 3, this implementation is now part of System.IO.Compression.dll and no longer the standalone System.IO.Compression.Zstandard.dll. Additionally, there is now a CRC32 check of the entries, so faulty archives are quickly detected. In ASP.NET Core, Zstandard can now be used for HTTP compression. However, this requires manual activation:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddResponseCompression();
builder.Services.AddRequestDecompression();
builder.Services.Configure<ZstandardCompressionProviderOptions>(options =>
{
    options.CompressionOptions = new ZstandardCompressionOptions
    {
        Quality = 6 // 1-22, higher = better compression, slower
    };
});

In the object-relational mapper Entity Framework Core, there is a new method GetEntriesForState() in the ChangeTracker class. This allows you to retrieve all objects that are in specific states (Added, Modified, Deleted, Unchanged), for example:

<Image Source="bild.png">
    <Image.GestureRecognizers>
        <LongPressGestureRecognizer
            MinimumPressDuration="1000"
            Command="{Binding ShowContextMenuCommand}" />
    </Image.GestureRecognizers>
</Image>

Unlike the previously available Entries() operation, which also provides this information, GetEntriesForState() does not call the DetectChanges() method beforehand, which can reduce performance in well-filled context instances.

In .NET MAUI, there is now a LongPressGestureRecognizer to react to a long press. You can specify the minimum duration in milliseconds from which the gesture should be considered "long":

<Image Source="bild.png">
    <Image.GestureRecognizers>
        <LongPressGestureRecognizer
            MinimumPressDuration="1000"
            Command="{Binding ShowContextMenuCommand}" />
    </Image.GestureRecognizers>
</Image>

The map control <Map> has also been improved in .NET MAUI 11.0 Preview 3.

According to the Release Notes, solution filter files can now be created not only via Visual Studio but also via the command line. Microsoft provides the following command-line code in the Release Notes:

dotnet new slnf --name MyApp.slnf
dotnet sln MyApp.slnf add src/Lib/Lib.csproj
dotnet sln MyApp.slnf list
dotnet sln MyApp.slnf remove src/Lib/Lib.csproj

However, a quick test showed that the implementation here is different from what the Release Notes indicate, because

dotnet new slnf --name MyApp.slnf

creates a file with a double file name extension: MyApp.slnf.slnf. Therefore, you have to omit the file name extension here, but it must be included in subsequent commands. Furthermore, the Microsoft example only works if the filter is to be named exactly like the solution file itself, only with .slnf instead of .slnx.

The following code shows a working implementation with a different name:

$ErrorActionPreference = "stop"
$slnf = "NET11Blazor.slnf"
$slnfWithExtension = "$slnf.slnf"
dotnet new slnf --name $slnf -s www.IT-Visions.de_NET11_Demos.slnx --force
dotnet sln $slnfWithExtension add NET11_BlazorServer/NET11_BlazorServer.csproj
dotnet sln $slnfWithExtension add NET11_BlazorWASMStandalone/NET11_BlazorWASMStandalone.csproj
dotnet sln $slnfWithExtension list
dotnet sln $slnfWithExtension add NET11_BlazorWASMStandalone/NET11_BlazorWASMStandalone.csproj
dotnet sln $slnfWithExtension list 

In .NET 10.0, Microsoft introduced so-called File-based Apps, which allow you to execute C# program code in a single source code file directly without a solution and without prior compilation. In .NET 11.0 Preview 3, this feature is extended to include include files. This allows you to include other files in a C# file via the new construct #:include:

#:include ./Datenklasse.cs#:include ./Hilfsroutinen.cs 

For this, you currently need the following additional line:

#:property ExperimentalFileBasedProgramEnableIncludeDirective=true

Visual Studio Code version 1.115 with the current C# Dev Kit version 3.11.200 reports that the editor does not yet recognize the feature:

Visual Studio Code does not yet know the meaning of #:include (Fig. 2).

When creating a database schema migration with Add-Migration or dotnet ef migrations add, Entity Framework Core now stores the name of the current schema migration in a variable named LatestMigrationId in the snapshot file, which represents the current object model state at the time the schema migration was created. Additionally, there is a comment there:

// If you encounter a merge conflict in the line below, it means you need to
// discard one of the migration branches and recreate its migrations on top of
// the other branch. See https://aka.ms/efcore-docs-migrations-conflicts for more info.
public override string LatestMigrationId => "20260415083524_v9";

This makes it easier to recognize version control conflicts in schema migrations.

Object model snapshot in Entity Framework Core 10.0 (left) vs. Entity Framework Core 11.0 (right) (Fig. 3)

Since .NET 11.0 Preview 2, the asynchronous runtime environment for .NET has been available, which directly understands async and await for the first time, without the compiler having to build a state machine for it in the background. In Preview 3, Microsoft has also implemented the asynchronous runtime environment in conjunction with the direct generation of machine code using ReadyToRun Images and Native AOT. Furthermore, the project setting <EnablePreviewFeatures>true</EnablePreviewFeatures> is no longer necessary. To set the asynchronous runtime environment, you only need to set <Features>runtime-async=on</Features>.

Further details can be found in the announcements for .NET version 11.0 Preview 3 and Visual Studio 2026 Insiders version 11709.129.

.NET 11.0 is scheduled for release in November 2026 and will have a standard term support of two years. Until then, four more preview versions are expected from May to August, as well as one release candidate version each in September and October.

(wpl)

Don't miss any news – follow us on Facebook, LinkedIn or Mastodon.

This article was originally published in German. It was translated with technical assistance and editorially reviewed before publication.