Union Types werden in .NET 11.0 Preview 3 endlich nutzbar

Die dritte Vorschauversion von .NET 11.0 macht die lang ersehnten Union Types in C# praktisch einsetzbar, weil nun auch die IDE Visual Studio mitspielt.

vorlesen Druckansicht 1 Kommentar lesen
Verkehrsschild mit Aufschrift .NET

(Bild: Pincasso / Shutterstock.com)

Lesezeit: 8 Min.
Von
  • Dr. Holger Schwichtenberg
Inhaltsverzeichnis
close notice

This article is also available in English. It was translated with technical assistance and editorially reviewed before publication.

Microsoft hat .NET-Version 11.0 Preview 3 zusammen mit Visual Studio 2026 Insiders Version 11709.129 veröffentlicht. .NET 11.0 umfasst die Sprachversion 15.0 der Programmiersprache C#.

Dr. Holger Schwichtenberg
Dr. Holger Schwichtenberg

Dr. Holger Schwichtenberg hat Fachbücher zu .NET 10.0, C# 14.0, Blazor 10.0 und Entity Framework Core 10.0 veröffentlicht. Er arbeitet als Berater und Trainer bei www.IT-Visions.de.

Eine Discriminated Union (oft auch Tagged Union, Algebraic Data Type oder Sum Type genannt) ist ein Datentyp, der genau einen von mehreren möglichen Typen enthalten kann, wobei jederzeit klar ist, welcher Typ gerade enthalten ist. Viele moderne Sprachen, wie F#, Rust, Swift oder TypeScript beherrschen dieses Konzept, andere wie Kotlin erlauben die Nachbildung. In C# musste man sich bisher mit dem Basistyp System.Object, Vererbung, Interfaces, Pattern Matching oder Umsetzungen auf Basis generischer Typen wie der Bibliothek OneOf behelfen.

Einen Union Type in C# 15.0 erstellen Entwicklerinnen und Entwickler mit dem neuen C#-SchlĂĽsselwort union unter Angabe eines Namens und von einem oder mehreren Typen, beispielsweise

public union UnionName(Typ1, Typ2, Typ3);

Dabei mĂĽssen die angegebenen Typen keinerlei Gemeinsamkeiten besitzen: Weder eine Basisklasse noch eine Schnittstelle mĂĽssen sie teilen. Das SchlĂĽsselwort null darf man nicht als Typ angeben. Nullable Values Types (z.B. int?) und Nullable Reference Types (Person?) sind aber erlaubt.

Videos by heise

Einer Variablen des Typs UnionName kann man dann sowohl Instanzen von Typ1 und Typ2 als auch Typ3 sowie gegebenenfalls null zuweisen. Jede andere Objekttypzuweisung wird aber verhindert.

Mit dem Operator is oder Pattern Matching lässt sich abfragen, welchen konkreten Typ die Union-Typvariable enthält. Während beim Pattern Matching automatisch ein Casting auf den Zieltyp erfolgt, muss man ohne Pattern Matching die Eigenschaft Value nutzen, um an den konkreten Typ zu kommen. Allerdings liefert Value den Typ System.Object, sodass wieder ein Casting erforderlich wird.

Als Beispiel soll ein typischer Einsatzfall für Discriminated Unions dienen: Eine Operation der Geschäftslogik kann neben einem konkreten Ergebnisobjekt mit Zeichenketten, Zahlen oder Exception-Objekten verschiedene Fehlerfälle signalisieren. Beim Einsatz von switch-Ausdrücken warnt der Compiler, wenn der Block nicht alle Fälle abfragt, mit der Warnung CS8509: „The switch expression does not handle all possible values of its input type (it is not exhaustive).“

Union Types haben das Potenzial, in einigen Teilen der .NET-Anwendungsframeworks für Vereinfachungen zu sorgen, beispielsweise bei WebAPI-Operationen mit Typed Results. Aktuell ist dies noch nicht möglich, aber es steht auf der Roadmap für ASP.NET WebAPIs und ASP.NET Core SignalR sowie Blazor zur Realisierung bis Jahresende 2026.

Folgender Code zeigt einen C# 15.0 Union Type für differenzierte Rückgabetypen einer Geschäftslogikmethode:

#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"
  });
 }
}

Das Beispiel demonstriert verschiedene Fälle beim Laden einer „Person“ anhand einer ID (Abb. 1).

Eine erste Unterstützung für Union Types gab es in Preview 2 von .NET 11.0 im März. Allerdings hatte Microsoft dieses Feature in dem Blogeintrag vergessen zu erwähnen. In den Release Notes zu .NET 11.0 Preview 2 führte der C#-Link ins Nirvana. Anfang April gab es dann einen Blogeintrag, der nachlieferte, dass Union Types in Preview 2 schon möglich waren, allerdings ohne Editor-Unterstützung. Der Editor funktioniert nun für Union Types in der Visual Studio-Version 2026 Insiders 11709.129.

Allerdings fehlt auch in Preview 3 immer noch ein StĂĽck Code in der Basisklassenbibliothek. Neben dem Tag <LangVersion>preview</LangVersion> in der Projektdatei mĂĽssen Entwicklerinnen und Entwickler daher auch den Inhalt des folgenden Listings mit der Implementierung der Annotation [Union] via Klasse UnionAttribute in jedes Projekt aufnehmen, das Union Types nutzen will:

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

Hintergrund ist, dass der C# 15.0-Compiler alle Union Types automatisch mit der Annotation [Union] versieht. Das erkennt man, wenn man den Union Type mit ILSpy dekompiliert:

// 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 hatte Microsoft die Komprimierung mit Zstandard als Alternative zu Deflate, GZip und Brotli eingeführt. In Preview 3 ist diese Implementierung nun Teil der System.IO.Compression.dll und nicht mehr der eigenständigen System.IO.Compression.Zstandard.dll. Zudem gibt es nun eine CRC32-Prüfung der Einträge, sodass fehlerhafte Archive schnell auffallen. In ASP.NET Core lässt sich Zstandard nun für die Komprimierung für HTTP verwenden. Das erfordert aber eine manuelle Aktivierung:

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
    };
});

Im Objekt-relationalen Mapper Entity Framework Core gibt es eine neue Methode GetEntriesForState() in der Klasse ChangeTracker. Hiermit kann man sich alle Objekte liefern lassen, die sich in bestimmten Zuständen (Added, Modified, Deleted, Unchanged) befinden, beispielsweise

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

Im Gegensatz zu der vorher schon verfĂĽgbaren Operation Entries(), die diese Informationen ebenfalls bereitstellt, ruft GetEntriesForState() nicht vorher die Methode DetectChanges() auf, die in gut gefĂĽllten Kontextinstanzen die Performance drĂĽcken kann.

In .NET MAUI gibt es nun einen LongPressGestureRecognizer, um auf längeres Drücken zu reagieren. Dabei kann man die Mindestdauer in Millisekunden angeben, ab wann die Geste als „lang“ gelten soll:

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

Auch das Landkartensteuerelement <Map> wurde in .NET MAUI 11.0 Preview 3 verbessert.

Laut Release Notes soll man nun Projektmappenfilter-Dateien nicht nur über Visual Studio, sondern auch per Kommandozeile erstellen können. Microsoft liefert dazu in den Release Notes diesen Kommandozeilencode:

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

Im Schnelltest zeigte sich aber, dass hier die Implementierung anders ist, als die Release Notes es anzeigen, denn

dotnet new slnf --name MyApp.slnf

legt eine Datei mit doppelter Dateinamenserweiterung an: MyApp.slnf.slnf. Man muss hier also die Dateinamenserweiterung weglassen, bei den Folgebefehlen muss sie aber stehen. Zudem funktioniert das Microsoft-Beispiel nur, wenn der Filter genau heiĂźen soll wie die Projektmappendatei selbst, nur mit .slnf statt .slnx.

Folgender Code zeigt eine funktionierende Umsetzung mit abweichendem Namen:

$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 hatte Microsoft sogenannte File-based Apps eingefĂĽhrt, mit denen man C#-Programmcode in einer einzelnen Quellcodedatei direkt ohne Projektmappe und ohne vorheriges Kompilieren ausfĂĽhren kann. In .NET 11.0 Preview 3 kommt die Erweiterung dieses Features um Include-Dateien. Damit kann man in einer C#-Datei andere Dateien ĂĽber das neue Konstrukt #:include einbinden:

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

Dafür benötigt man aktuell zusätzlich folgende Zeile:

#:property ExperimentalFileBasedProgramEnableIncludeDirective=true

Visual Studio Code Version 1.115 mit der aktuellen C# Dev Kit Version 3.11.200 meldet, dass der Editor das Feature noch nicht kennt:

Visual Studio Code kennt die Bedeutung von #:include noch nicht (Abb. 2).

Beim Anlegen einer Datenbankschemamigration mit Add-Migration beziehungsweise dotnet ef migrations add speichert Entity Framework Core in der Snapshot-Datei, die den aktuellen Objektmodellzustand zum Zeitpunkt der Erstellung der Schemamigration repräsentiert, nun den Namen der aktuellen Schemamigration in einer Variablen mit dem Namen LatestMigrationId. Zudem gibt es dort einen Kommentar:

// 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";

Damit ist leichter erkennbar, dass es einen Versionsverwaltungskonflikt bei den Schemamigrationen gibt.

Objektmodell-Snapshot in Entity Framework Core 10.0 (links) vs. Entity Framework Core 11.0 (rechts) (Abb. 3)

Seit .NET 11.0 Preview 2 gibt es die asynchrone Laufzeitumgebung fĂĽr .NET, die erstmals direkt async und await versteht, ohne dass der Compiler im Hintergrund eine State Machine dafĂĽr bauen muss. In Preview 3 hat Microsoft die asynchrone Laufzeitumgebung auch in Verbindung mit der direkten Erzeugung von Maschinencode mit ReadyToRun Images und Native AOT implementiert. Zudem ist die Projekteinstellung <EnablePreviewFeatures>true</EnablePreviewFeatures> nicht mehr notwendig. Um die asynchrone Laufzeitumgebung zu setzen, muss man nur noch <Features>runtime-async=on</Features> setzen.

Weitere Details finden sich in den AnkĂĽndigungen zu .NET-Version 11.0 Preview 3 und Visual Studio 2026 Insiders Version 11709.129.

.NET 11.0 soll im November 2026 erscheinen und einen Standard-Term-Support von zwei Jahren erhalten. Bis dahin ist mit vier weiteren Preview-Versionen von Mai bis August sowie jeweils einer Release-Candidate-Version im September und Oktober zu rechnen.

(rme)