Sooner or later every C# developer needs a value that is "one of these types and nothing else": a payment that either succeeded, was declined or failed; a pet that is a cat, a dog or a bird; a sensor reading that is a number or a status text. Until now we modelled this with a marker interface, an abstract base class, or a OneOf<...> library, and none of them let the compiler check that every case was handled.
C# 15, which ships with .NET 11, adds union types to the language. A union declares a closed set of case types, converts from each of them implicitly, and makes switch expressions exhaustive. In this lesson we build a console app that covers the language rules, then use the same unions in an ASP.NET Core Minimal API.
Technical Requirements
- .NET 11 SDK, RC 2 or later. This lesson was tested with
11.0.100-rc.2.26504.105. .NET 11 is scheduled for general availability in November 2026. - C# 15 is the default language version for projects that target
net11.0, so you don't need to set LangVersion. - A recent IDE: Visual Studio 2026, Rider, or VS Code with C# Dev Kit.
- The full source code is on GitHub: Fcakiroglu16/dotnetacademy, branch csharp15-union-types
The repository contains two projects:
src/UnionTypes.ConsoleApp: the language features, one demo class per topicsrc/UnionTypes.Api: a Minimal API that uses unions in request and response bodies
git clone https://github.com/Fcakiroglu16/dotnetacademy.git
cd dotnetacademy
git switch csharp15-union-types
dotnet run --project src/UnionTypes.ConsoleApp
What Is a Union Type?
A union type represents a value that is exactly one of several case types. Declare it with the union keyword:
public union Pet(Cat, Dog, Bird);
Reach for a union when a value must be one of a fixed set of types and you want the compiler to make sure every possibility is handled. Typical uses:
- Result-or-error returns: a method returns a success value or an error value, and the caller must handle both.
- Message or command dispatching: a system processes a closed set of message types. Adding a new type produces a warning at every
switch that doesn't handle it yet. - Replacing marker interfaces and abstract base classes that exist only to group types for pattern matching.
A union is different from the other type declarations you know:
- Unlike a
class or struct, a union doesn't define new data. It composes existing types into a closed set of alternatives. - Unlike an
interface, a union is closed. The declaration lists every case, and the compiler uses that list for exhaustiveness checks. - Unlike a
record, a union adds no equality, cloning or deconstruction. It answers "which case is it?", not "what fields does it have?" - The case types don't need a common base type.
Cat, Dog and Bird are three unrelated records.
Declaring a Union and Implicit Conversions
Our pets are plain records with nothing in common. The Pet union ties them together:
src/UnionTypes.ConsoleApp/Models/Pets.cs
namespace UnionTypes.ConsoleApp.Models;
public record class Cat(string Name, int Lives);
public record class Dog(string Name, string Breed);
public record class Bird(string Name, bool CanTalk);
// A union composes existing types into a closed set of alternatives.
public union Pet(Cat, Dog, Bird);
Every case type converts to the union implicitly, so you assign a Dog or a Cat to a Pet variable without calling a constructor or factory. The wrapped object is available through the Value property:
src/UnionTypes.ConsoleApp/Demos/BasicsDemo.cs
using UnionTypes.ConsoleApp.Models;
namespace UnionTypes.ConsoleApp.Demos;
internal static class BasicsDemo
{
public static void Run()
{
Demo.Title("1. Declaration and implicit conversion");
// Every case type converts to the union implicitly.
Pet pet = new Dog("Rex", "Husky");
Console.WriteLine(pet.Value); // Dog { Name = Rex, Breed = Husky }
pet = new Cat("Whiskers", 9);
Console.WriteLine(pet.Value); // Cat { Name = Whiskers, Lives = 9 }
// The compiler emits a struct, so Pet is a value type.
Console.WriteLine(typeof(Pet).IsValueType); // True
Pet[] pets = [new Dog("Rex", "Husky"), new Cat("Misty", 7), new Bird("Kiwi", true)];
foreach (var p in pets)
{
Console.WriteLine(Describe(p));
}
}
// Exhaustive switch: no discard arm needed. Remove the Bird arm and the
// compiler warns that the switch doesn't handle every case type.
public static string Describe(Pet pet) => pet switch
{
Dog d => $"{d.Name} is a {d.Breed}",
Cat c => $"{c.Name} has {c.Lives} lives left",
Bird b => b.CanTalk ? $"{b.Name} can talk" : $"{b.Name} only sings",
};
}
Output:
=== 1. Declaration and implicit conversion ===
Dog { Name = Rex, Breed = Husky }
Cat { Name = Whiskers, Lives = 9 }
True
Rex is a Husky
Misty has 7 lives left
Kiwi can talk
Exhaustive Pattern Matching
Look at the Describe method again. The switch expression has no discard (_) arm, and the compiler doesn't complain, because it knows that a Pet can only be a Dog, a Cat or a Bird.
Pattern matching on a union is transparent: patterns such as Dog d are applied to the union's Value, not to the union struct itself. You write the patterns as if you were matching the case object directly.
Now delete the Bird arm and build again:
warning CS8509: The switch expression does not handle all possible values of its
input type (it is not exhaustive). For example, the pattern
'UnionTypes.ConsoleApp.Models.Bird' is not covered.
This is the real benefit. When you add a fourth case such as Fish to Pet, every switch in the code base that doesn't handle it lights up with CS8509. With an interface or a base class plus _ => throw ..., the same mistake only shows up at run time.
Tip: to make missing cases fail the build, add <WarningsAsErrors>CS8509</WarningsAsErrors> to the project file.
The Result-or-Error Pattern
Expected failures such as a declined card are not exceptional, yet many code bases still model them with exceptions or with a bool Success plus nullable fields. A union lets the method return exactly one of three outcomes:
src/UnionTypes.ConsoleApp/Models/Payments.cs
namespace UnionTypes.ConsoleApp.Models;
public record class PaymentSucceeded(string TransactionId, decimal Amount);
public record class PaymentDeclined(string Reason);
public record class PaymentFailed(Exception Error);
// Result-or-error: the caller must handle every outcome.
public union PaymentResult(PaymentSucceeded, PaymentDeclined, PaymentFailed);
src/UnionTypes.ConsoleApp/Demos/ResultDemo.cs
using UnionTypes.ConsoleApp.Models;
namespace UnionTypes.ConsoleApp.Demos;
internal static class ResultDemo
{
public static void Run()
{
Demo.Title("2. Result-or-error with a union");
foreach (var amount in new[] { 150m, 25_000m, -1m })
{
PaymentResult result = Charge(amount);
string message = result switch
{
PaymentSucceeded ok => $"OK {ok.TransactionId} ({ok.Amount:C})",
PaymentDeclined declined => $"NO {declined.Reason}",
PaymentFailed failed => $"ERR {failed.Error.Message}",
};
Console.WriteLine(message);
}
}
// No exceptions for expected outcomes, no shared base class.
private static PaymentResult Charge(decimal amount)
{
if (amount <= 0)
{
return new PaymentFailed(new ArgumentOutOfRangeException(nameof(amount), "Amount must be positive."));
}
if (amount > 10_000m)
{
return new PaymentDeclined("Limit exceeded");
}
return new PaymentSucceeded($"TX-{Random.Shared.Next(1000, 9999)}", amount);
}
}
Charge returns a PaymentSucceeded, a PaymentDeclined or a PaymentFailed, and each return statement converts implicitly to PaymentResult. The caller can't forget a case:
=== 2. Result-or-error with a union ===
OK TX-3555 ($150.00)
NO Limit exceeded
ERR Amount must be positive. (Parameter 'amount')
Adding Members to a Union
A union declaration can have a body with methods and computed properties, like a struct. It can't declare instance fields, auto-properties or field-like events, because the only state a union holds is its Value. You also can't declare a public constructor with a single parameter, because the compiler generates one per case type.
Inside the union, this switch matches on the current case:
src/UnionTypes.ConsoleApp/Models/Length.cs
namespace UnionTypes.ConsoleApp.Models;
public record class Meters(double Value);
public record class Feet(double Value);
// A union can declare members, but no instance fields or auto-properties.
public union Length(Meters, Feet)
{
public double TotalMeters => this switch
{
Meters m => m.Value,
Feet f => f.Value * 0.3048,
null => throw new InvalidOperationException("The Length has no value."),
};
public Length Add(Length other) => new Meters(TotalMeters + other.TotalMeters);
}
src/UnionTypes.ConsoleApp/Demos/MembersDemo.cs
using UnionTypes.ConsoleApp.Models;
namespace UnionTypes.ConsoleApp.Demos;
internal static class MembersDemo
{
public static void Run()
{
Demo.Title("3. Members on a union");
Length a = new Meters(10);
Length b = new Feet(10);
Length total = a.Add(b);
Console.WriteLine($"{a.TotalMeters} m + {b.TotalMeters:F3} m = {total.TotalMeters:F3} m");
Console.WriteLine(total.Value); // Meters { Value = 13.048 }
}
}
=== 3. Members on a union ===
10 m + 3.048 m = 13.048 m
Meters { Value = 13.048 }
Add returns a Meters, which converts to Length implicitly.
Value-Type Cases and Generic Unions
Case types can be any type that converts to object: classes, records, structs, interfaces, type parameters, nullable types, or even other unions. They can be primitives too:
src/UnionTypes.ConsoleApp/Models/Primitives.cs
namespace UnionTypes.ConsoleApp.Models;
// Value-type cases are boxed into the object? Value property.
public union IntOrString(int, string);
public record class None;
public record class Some<T>(T Value);
// Generic unions work too.
public union Option<T>(None, Some<T>);
src/UnionTypes.ConsoleApp/Demos/ValueTypesAndGenericsDemo.cs
using UnionTypes.ConsoleApp.Models;
namespace UnionTypes.ConsoleApp.Demos;
internal static class ValueTypesAndGenericsDemo
{
public static void Run()
{
Demo.Title("4. Value-type cases and generic unions");
IntOrString[] values = [42, "forty-two"];
foreach (var value in values)
{
string text = value switch
{
int i => $"int -> {i * 2}",
string s => $"string -> {s.ToUpperInvariant()}",
};
Console.WriteLine(text);
}
// int cases are boxed: Value is object?.
IntOrString number = 7;
Console.WriteLine(number.Value?.GetType()); // System.Int32
Option<string> found = new Some<string>("dotnetacademy.dev");
Option<string> missing = new None();
Console.WriteLine(Show(found));
Console.WriteLine(Show(missing));
static string Show(Option<string> option) => option switch
{
Some<string> some => $"Some({some.Value})",
None => "None",
};
}
}
=== 4. Value-type cases and generic unions ===
int -> 84
string -> FORTY-TWO
System.Int32
Some(dotnetacademy.dev)
None
Keep one cost in mind: a compiler-generated union stores its content in a single object? Value. A value-type case such as int is therefore boxed. That is fine for most application code. For hot paths, see the custom union section below.
default, null and IUnion
The compiler turns union Pet(...) into a struct, so default(Pet) exists. It holds no case, and its Value is null. This happens with unassigned fields, array elements and default expressions.
src/UnionTypes.ConsoleApp/Demos/NullAndDefaultDemo.cs
using System.Runtime.CompilerServices;
using UnionTypes.ConsoleApp.Models;
namespace UnionTypes.ConsoleApp.Demos;
internal static class NullAndDefaultDemo
{
public static void Run()
{
Demo.Title("5. default, null and IUnion");
// A default struct union holds no case.
Pet empty = default;
Console.WriteLine(empty.Value is null); // True
Console.WriteLine(DescribeOrNone(empty)); // no pet
Console.WriteLine(DescribeOrNone(new Dog("Rex", "Husky")));
// Without a null arm, a default union throws at run time.
try
{
Console.WriteLine(BasicsDemo.Describe(empty));
}
catch (SwitchExpressionException)
{
Console.WriteLine("SwitchExpressionException: default(Pet) reached a switch without a null arm");
}
// Nullable union: Pet? is Nullable<Pet>.
Pet? maybe = null;
Console.WriteLine(maybe is null); // True
// Every union implements IUnion, so you can inspect any union at run time.
object boxed = new Pet(new Bird("Kiwi", true));
if (boxed is IUnion { Value: { } inner })
{
Console.WriteLine($"IUnion holds a {inner.GetType().Name}");
}
}
private static string DescribeOrNone(Pet pet) => pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
null => "no pet",
};
}
=== 5. default, null and IUnion ===
True
no pet
Rex
SwitchExpressionException: default(Pet) reached a switch without a null arm
True
IUnion holds a Bird
The rules to remember:
- When no case type is nullable, the compiler treats
Value as not-null and doesn't require a null arm. A default union that reaches such a switch throws SwitchExpressionException at run time. Add a null arm wherever a default value can appear. - When any case type is nullable, the compiler requires a
null arm (warning CS8655). - On a struct union, the
null pattern checks whether Value is null. Pet? is Nullable<Pet>, and null matches both an empty Nullable and an empty union. _, var and not patterns are the exceptions to unwrapping: they apply to the union value itself, not to Value. That is also why pet is Pet is always true (warning CS0183). Check pet.Value is not null instead.- Every union implements
System.Runtime.CompilerServices.IUnion, so value is IUnion { Value: ... } inspects any union at run time.
What the Compiler Generates: Writing a Union by Hand
public union Pet(Cat, Dog, Bird); is shorthand. The compiler emits roughly this:
[Union] public struct Pet : IUnion
{
public Pet(Cat value) => Value = value;
public Pet(Dog value) => Value = value;
public Pet(Bird value) => Value = value;
public object? Value { get; }
}
Any class or struct that follows this basic union pattern is treated as a union by the compiler:
- a
[Union] attribute (System.Runtime.CompilerServices.UnionAttribute), - one public constructor with a single parameter per case type,
- a public
object? Value property.
You get conversions, exhaustiveness and transparent pattern matching for free:
src/UnionTypes.ConsoleApp/Models/Shape.cs
using System.Runtime.CompilerServices;
namespace UnionTypes.ConsoleApp.Models;
public record class Circle(double Radius);
public record class Rectangle(double Width, double Height);
// A hand-written union: [Union] + one single-parameter constructor per case + Value.
[Union]
public struct Shape : IUnion
{
private readonly object? _value;
public Shape(Circle value) => _value = value;
public Shape(Rectangle value) => _value = value;
public object? Value => _value;
}
src/UnionTypes.ConsoleApp/Demos/CustomUnionDemo.cs
using UnionTypes.ConsoleApp.Models;
namespace UnionTypes.ConsoleApp.Demos;
internal static class CustomUnionDemo
{
public static void Run()
{
Demo.Title("6. A hand-written [Union] type");
Shape[] shapes = [new Circle(5), new Rectangle(3, 4)];
foreach (var shape in shapes)
{
double area = shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
};
Console.WriteLine($"{shape.Value!.GetType().Name,-9} area = {area:F2}");
}
}
}
=== 6. A hand-written [Union] type ===
Circle area = 78.54
Rectangle area = 12.00
Write a union by hand when the generated form doesn't fit:
- Class-based unions for reference semantics: put
[Union] on a class. - No boxing: add a
bool HasValue property and a bool TryGetValue(out T value) method per case. The compiler then calls TryGetValue for type patterns and HasValue for null patterns, and value-type cases are never boxed. - Private constructors or factories: declare a nested
IUnionMembers interface with static Create methods. This is called a union member provider. - Adapting an existing type that you can't redeclare as a
union.
Unions and System.Text.Json
.NET 11's System.Text.Json understands unions. The serializer writes the active case as-is, with no wrapper object and no $type discriminator, and it reads the value back by looking at the JSON token kind:
src/UnionTypes.ConsoleApp/Models/Notifications.cs
using System.Text.Json.Serialization;
namespace UnionTypes.ConsoleApp.Models;
public record class EmailNotification(string To, string Subject);
public record class SmsNotification(string PhoneNumber, string Text);
// Both cases are JSON objects, so the default token-kind classifier can't tell them apart.
// The structural classifier picks the case by its root-level property names.
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Notification(EmailNotification, SmsNotification);
src/UnionTypes.ConsoleApp/Demos/JsonDemo.cs
using System.Text.Json;
using UnionTypes.ConsoleApp.Models;
namespace UnionTypes.ConsoleApp.Demos;
internal static class JsonDemo
{
public static void Run()
{
Demo.Title("7. System.Text.Json and unions");
// The active case is written as-is: no wrapper, no $type discriminator.
Console.WriteLine(JsonSerializer.Serialize<IntOrString>(42)); // 42
Console.WriteLine(JsonSerializer.Serialize<IntOrString>("hello")); // "hello"
Console.WriteLine(JsonSerializer.Serialize(new Pet(new Cat("Misty", 7)))); // {"Name":"Misty","Lives":7}
Console.WriteLine(JsonSerializer.Serialize(default(Pet))); // null
// Reading back: the JSON token kind (number/string) selects the case.
IntOrString n = JsonSerializer.Deserialize<IntOrString>("123");
IntOrString s = JsonSerializer.Deserialize<IntOrString>("\"abc\"");
Console.WriteLine($"{n.Value?.GetType().Name}: {n.Value}, {s.Value?.GetType().Name}: {s.Value}");
// Two object cases: [JsonUnion] with the structural classifier decides by property names.
string[] payloads =
[
"""{"PhoneNumber":"+905551112233","Text":"Your code is 1234"}""",
];
foreach (var json in payloads)
{
Notification notification = JsonSerializer.Deserialize<Notification>(json);
string sent = notification switch
{
EmailNotification e => $"email to {e.To}: {e.Subject}",
SmsNotification sms => $"sms to {sms.PhoneNumber}: {sms.Text}",
};
Console.WriteLine(sent);
}
// Without a classifier, Pet(Cat, Dog, Bird) can't pick an object case.
try
{
JsonSerializer.Deserialize<Pet>("""{"Name":"Rex","Breed":"Husky"}""");
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
}
}
=== 7. System.Text.Json and unions ===
42
"hello"
{"Name":"Misty","Lives":7}
null
Int32: 123, String: abc
sms to +905551112233: Your code is 1234
JsonException: JSON value type 'Object' is ambiguous for union type 'UnionTypes.ConsoleApp.Models.Pet' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
How deserialization picks a case:
- By token kind (default). A JSON number selects
int, a JSON string selects string. This works for IntOrString with no extra configuration. - Two or more object cases are ambiguous.
Pet has three object cases, so the default classifier throws the JsonException above. - **
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]** selects the object case by its root-level property names. To/Subject means EmailNotification, and PhoneNumber/Text means SmsNotification. The structural classifier doesn't look at property values or nested objects. If zero or several cases remain, it throws. - For anything else, derive from
JsonTypeClassifierFactory and register it on the union, on JsonSerializerOptions.TypeClassifiers, or on a source-generation context. - A
default struct union serializes as JSON null.
Using Unions in an ASP.NET Core Minimal API
ASP.NET Core in .NET 11 supports unions everywhere System.Text.Json is used: Minimal API and MVC request and response bodies, SignalR's JSON hub protocol, Blazor JS interop and component state. Let's build a small API with the same ideas.
The models
Because Pet now arrives in request bodies, it needs the structural classifier. Its three cases have distinct property names (lives, breed, canTalk), so the classifier can tell them apart:
src/UnionTypes.Api/Models/Pets.cs
using System.Text.Json.Serialization;
namespace UnionTypes.Api.Models;
public record class Cat(string Name, int Lives);
public record class Dog(string Name, string Breed);
public record class Bird(string Name, bool CanTalk);
// All three cases are JSON objects; the structural classifier picks the case
// from the property names in the request body (lives / breed / canTalk).
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Pet(Cat, Dog, Bird);
public record class PetEntry(int Id, Pet Pet);
src/UnionTypes.Api/Models/Payments.cs
namespace UnionTypes.Api.Models;
public record class PaymentRequest(string CardNumber, decimal Amount);
public record class PaymentSucceeded(string TransactionId, decimal Amount);
public record class PaymentDeclined(string Reason);
public record class PaymentFailed(string Error);
public union PaymentResult(PaymentSucceeded, PaymentDeclined, PaymentFailed);
src/UnionTypes.Api/Models/Readings.cs
namespace UnionTypes.Api.Models;
// A sensor reports a number, or a status text when it has no value.
// int and string use different JSON token kinds, so no classifier is needed.
public union Reading(int, string);
public record class SensorReading(string Sensor, Reading Value);
The services
src/UnionTypes.Api/Services/PetStore.cs
using System.Collections.Concurrent;
using UnionTypes.Api.Models;
namespace UnionTypes.Api.Services;
public class PetStore
{
private readonly ConcurrentDictionary<int, Pet> _pets = new()
{
[1] = new Dog("Rex", "Husky"),
[2] = new Cat("Misty", 7),
[3] = new Bird("Kiwi", true),
};
private int _nextId = 3;
public IEnumerable<PetEntry> GetAll() =>
_pets.OrderBy(p => p.Key).Select(p => new PetEntry(p.Key, p.Value));
public Pet? Find(int id) => _pets.TryGetValue(id, out var pet) ? pet : (Pet?)null;
public PetEntry Add(Pet pet)
{
int id = Interlocked.Increment(ref _nextId);
_pets[id] = pet;
return new PetEntry(id, pet);
}
}
One small gotcha in Find: with a union, condition ? pet : null doesn't compile (CS0037, "Cannot convert null to 'Pet' because it is a non-nullable value type"). The cast (Pet?)null gives the conditional expression its Nullable<Pet> type.
src/UnionTypes.Api/Services/PaymentService.cs
using UnionTypes.Api.Models;
namespace UnionTypes.Api.Services;
public class PaymentService
{
// Expected outcomes are values, not exceptions.
public PaymentResult Charge(PaymentRequest request)
{
if (request.Amount <= 0)
{
return new PaymentFailed("Amount must be positive.");
}
if (request.CardNumber.EndsWith("0000"))
{
return new PaymentDeclined("Card blocked by issuer.");
}
if (request.Amount > 10_000m)
{
return new PaymentDeclined("Limit exceeded.");
}
return new PaymentSucceeded($"TX-{Guid.NewGuid():N}"[..11], request.Amount);
}
}
The endpoints
src/UnionTypes.Api/Program.cs
using Microsoft.AspNetCore.Http.HttpResults;
using UnionTypes.Api.Models;
using UnionTypes.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<PetStore>();
builder.Services.AddSingleton<PaymentService>();
var app = builder.Build();
app.MapOpenApi();
// 1. Union in the response body: each item is written as its active case.
app.MapGet("/pets", (PetStore store) => store.GetAll());
app.MapGet("/pets/{id:int}", Results<Ok<Pet>, NotFound> (int id, PetStore store) =>
store.Find(id) is { } pet ? TypedResults.Ok(pet) : TypedResults.NotFound());
// 2. Union in the request body: the JSON shape decides Cat, Dog or Bird.
app.MapPost("/pets", Created<PetEntry> (Pet pet, PetStore store) =>
{
var entry = store.Add(pet);
return TypedResults.Created($"/pets/{entry.Id}", entry);
});
app.MapGet("/pets/{id:int}/sound", Results<Ok<string>, NotFound> (int id, PetStore store) =>
{
if (store.Find(id) is not { } pet)
{
return TypedResults.NotFound();
}
// Exhaustive: adding a case to Pet turns this into a compiler warning.
string sound = pet switch
{
Dog d => $"{d.Name}: Woof!",
Cat c => $"{c.Name}: Meow!",
Bird b => b.CanTalk ? $"{b.Name}: Hello!" : $"{b.Name}: Tweet!",
};
return TypedResults.Ok(sound);
});
// 3. Result-or-error: map every union case to an HTTP response.
app.MapPost("/payments", Results<Ok<PaymentSucceeded>, UnprocessableEntity<PaymentDeclined>, BadRequest<PaymentFailed>>
(PaymentRequest request, PaymentService payments) =>
payments.Charge(request) switch
{
PaymentSucceeded ok => TypedResults.Ok(ok),
PaymentDeclined declined => TypedResults.UnprocessableEntity(declined),
PaymentFailed failed => TypedResults.BadRequest(failed),
});
// 4. Value-type cases: a number or a status text in the same field.
app.MapGet("/readings", () => new SensorReading[]
{
new("temperature", 21),
new("humidity", 48),
new("pressure", "offline"),
});
app.Run();
Note the /payments endpoint. The service returns a PaymentResult union, and one exhaustive switch maps each case to a typed HTTP result: success to 200, declined to 422, failed to 400. If someone adds a fourth case to PaymentResult, this endpoint stops compiling cleanly until it handles the new case.
Testing the API
Start the API:
dotnet run --project src/UnionTypes.Api
The repository includes UnionTypes.Api.http for Rider, Visual Studio and VS Code. These are the curl calls and the responses from the test run:
$ curl http://localhost:5080/pets
[{"id":1,"pet":{"name":"Rex","breed":"Husky"}},{"id":2,"pet":{"name":"Misty","lives":7}},{"id":3,"pet":{"name":"Kiwi","canTalk":true}}]
$ curl http://localhost:5080/pets/2
{"name":"Misty","lives":7}
$ curl -X POST http://localhost:5080/pets -H "Content-Type: application/json" -d '{"name":"Karabas","breed":"Kangal"}'
HTTP 201 {"id":4,"pet":{"name":"Karabas","breed":"Kangal"}}
$ curl -X POST http://localhost:5080/pets -H "Content-Type: application/json" -d '{"name":"Tekir","lives":9}'
HTTP 201 {"id":5,"pet":{"name":"Tekir","lives":9}}
$ curl -X POST http://localhost:5080/pets -H "Content-Type: application/json" -d '{"name":"Nemo"}'
HTTP 400 (the classifier finds no matching case)
$ curl http://localhost:5080/pets/4/sound
"Karabas: Woof!"
$ curl -X POST http://localhost:5080/payments -H "Content-Type: application/json" -d '{"cardNumber":"4111111111111111","amount":150}'
HTTP 200 {"transactionId":"TX-0057dbfa","amount":150}
$ curl -X POST http://localhost:5080/payments -H "Content-Type: application/json" -d '{"cardNumber":"4111111111110000","amount":150}'
HTTP 422 {"reason":"Card blocked by issuer."}
$ curl -X POST http://localhost:5080/payments -H "Content-Type: application/json" -d '{"cardNumber":"4111111111111111","amount":-5}'
HTTP 400 {"error":"Amount must be positive."}
$ curl http://localhost:5080/readings
[{"sensor":"temperature","value":21},{"sensor":"humidity","value":48},{"sensor":"pressure","value":"offline"}]
The Dog in the request body was selected only by its breed property, and the Cat by lives. In /readings, the same value field carries a number or a string.
Unions in the OpenAPI document
.NET 11 generates OpenAPI 3.2 documents by default, and a union becomes an anyOf schema that reuses each case's own component. From /openapi/v1.json:
"Pet": {
"type": "object",
"anyOf": [
{ "$ref": "#/components/schemas/Cat" },
{ "$ref": "#/components/schemas/Dog" },
{ "$ref": "#/components/schemas/Bird" }
]
}
"Reading": {
"anyOf": [
{ "type": ["integer", "string"], "format": "int32", "pattern": "^-?(?:0|[1-9]\\d*)$" },
{ "type": "string" }
]
}
ApiExplorer detects unions through JsonTypeInfoKind.Union, so Swashbuckle and NSwag see the same schema.
Limitations in ASP.NET Core
- Unions are supported for JSON bodies only. Route values, query strings, headers and form fields can't bind to a union.
- SignalR supports unions with the JSON hub protocol only. The MessagePack and Newtonsoft.Json protocols don't.
Unions or Closed Hierarchies?
C# 15 also adds closed classes, which we cover in the next lesson. Both give you exhaustive switch expressions. Choose based on these differences:
- Union: the cases are unrelated, existing types (even
int and string) with no common base class. The JSON stays discriminator-free, which helps when you must match a JSON format you don't control. - Closed hierarchy: the cases share a base class and behavior that you own. With
[JsonPolymorphic(InferClosedTypePolymorphism = true)], the JSON carries a $type discriminator, so case selection doesn't depend on property names that may change later.
Summary
public union Pet(Cat, Dog, Bird); declares a closed set of case types. The case types need no common base type.- Each case type converts to the union implicitly. The wrapped object is in
Value. - Pattern matching unwraps the union, and a
switch that covers every case needs no discard arm. A missing case produces CS8509. - Unions can declare methods and computed properties, but no instance fields or auto-properties.
- The generated union is a struct that boxes value-type cases.
default holds no case, so add a null arm where defaults can appear. - Any type with
[Union], single-parameter constructors and an object? Value is a union. Add HasValue and TryGetValue to avoid boxing. - System.Text.Json writes the active case without a wrapper. Use
[JsonUnion] with JsonUnionTypeStructuralClassifier when several cases are JSON objects. - In ASP.NET Core, unions work in JSON request and response bodies and appear as
anyOf in OpenAPI 3.2.
Source code: github.com/Fcakiroglu16/dotnetacademy/tree/csharp15-union-types