Showing posts with label C# 15. Show all posts
Showing posts with label C# 15. Show all posts

Sunday, 6 September 2026

Listing all case types of C# 15 Unions

This article presents some C# 15 extension methods to discover the case types of unions in C# 15. This is the Language version coming to .NET 11 in November, 2026 (estimated release date). The code is already possible to test out. You will need to properly test this before .NET 11 ships:
  • NET 11 sdk (preview)
  • Vscode insiders version
  • C# extension in Vscode updated to preview version
  • C# devkit extension in Vscode updated to preview version
Please note that the .csproj must be set to preview Example .csproj :

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

The Github repo for the code in this article is here:



C# Unions are coming in C# 15 and .NET 11. It seems like listing up the case types still needs you to use reflection. Probably we will see union types get more features in later C# versions, they are in C# 15 quite a new thing to C# and the language team of C# spent quite a few year to get it to work with the language and CLR. Shown below is an extension method that uses reflection in C# to discover the union case types. It is based that a union always implements the interface IUnion , often implicitly via just using the union keyword - in case it is not a Manual Union. By inspecting the mandatory constructors of the union and retrieving the first parameter's type , we can get the types of the union.

UnionExtensions.cs



/// <summary>
/// Provides reflection-based helpers for C# unions.
/// </summary>
public static class UnionExtensions
{
    /// <summary>
    /// Retrieves the union case types from generic type reference (compile-time checked)
    /// </summary>
    /// <typeparam name="TUnion">The union type to inspect.</typeparam>
    /// <returns>The distinct case types exposed by the union constructors.</returns>
    public static IReadOnlyList<Type> GetCaseTypes<TUnion>() where TUnion : IUnion
    {
        return GetUnionCaseTypes(typeof(TUnion));
    }

    /// <summary>
    /// Retrieves the union case types from a runtime type reference.
    /// </summary>
    /// <param name="t">The union type to inspect.</param>
    /// <returns>The distinct case types exposed by the union constructors.</returns>
    public static IReadOnlyList<Type> GetUnionCaseTypes(this Type t){

            if (t == null || !typeof(IUnion).IsAssignableFrom(t)){
                return Array.Empty<Type>(); //guard against null or non-union types, just return an empty result in this case
            }
  
            return t
                .GetConstructors(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
                .Where(constructor => constructor.GetParameters().Length == 1)
                .Select(constructor => constructor.GetParameters()[0].ParameterType)
                .Distinct()
                .ToArray();
    }
   
}


The method below does not throw an exception in case the type t is null or a type that is not assignable from IUnion, i..e not a type that is a union type. Instead it just returns an empty array. Although .NET 11 is expected to be shipped in November this year (2026), it does not seem like an easy way to get the case types of a union can be easily done without such a helper method, there will probably not be a language specific way of retrieving the case types of the union. Below is sample code demonstrating the usage of the union case type listing helper extension method shown above. First off, defining a union type that has four records and also common behavior, adding a description expression bodied method.


public record Dog(string Name);
public record Cat(int NumberOfLives, string? Name = default);
public record Parrot(bool WantsCrackers, string? Name = default);
public record GoldFish(bool MakesBubbles, string? Name = default);


union Pet(Dog, Cat, Parrot, GoldFish)
{

    public string Description => this switch
    {
        Cat cat => $"{cat.Name} says: Meow! I got {cat.NumberOfLives} lives left 🐈",
        Dog dog => $"Bark Bark! {dog.Name} says! 🦴 🐕",
        Parrot parrot => $"🦜Squawk! {parrot.Name} says: {(parrot.WantsCrackers ? "I want crackers!" : "Give us a kiss!")}",
        GoldFish goldFish => $"🐠 {goldFish.Name} says: Blub Blub! {(goldFish.MakesBubbles ? "I make bubbles!" : "I don't make bubbles!")}"
    };
}


The demo code below then makes use of the Pet union type defined and using the extension helper method. Please note the usage of the method that uses the parameterless method GetCaseTypes that is static compliler checked via the passed in type argument and the use of the GetUnionCaseTypes, that is runtime based where the type is passed in to the method. As shown in the GetUnionCaseTypes, erroneous usage is guarded. In case a wrong type is passed in and is not a union type which implements IUnion, an empty array is returned.


using System.Runtime.CompilerServices;

/// <summary>
/// Demonstrates discovering the case types of a C# union.
/// </summary>
public class Union1Demo
{

    /// <summary>
    /// Runs the union case discovery and pattern-matching examples.
    /// </summary>
    public static void RunDemo()
    {
        Console.WriteLine($"Pet union cases: {string.Join(", ", UnionExtensions.GetCaseTypes<Pet>().Select(type => type.Name))}");

        var somePets = new Pet[]{
            new Dog("Rex"),
            new Cat(7, "Whiskers"),
            new Parrot(true, "Polly"),
            new GoldFish(true, "Timmy")
        };

        foreach (var pet in somePets)
        {
            Console.WriteLine(pet.Description);    
        }

        Console.WriteLine();
        Console.WriteLine("Listing all pet union case types:\n-----------------------------------------");
        foreach (var caseType in typeof(Pet).GetUnionCaseTypes())
        {
            Console.Write($"* {caseType.FullName}");
            Console.WriteLine($" with props: {string.Join(", ", caseType.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public).Select(p => p.Name))}");
        }

    }

}


The output of running the demo code is shown below:


Pet union cases: Dog, Cat, Parrot, GoldFish
Bark Bark! Rex says! 🦴 🐕
Whiskers says: Meow! I got 7 lives left 🐈
🦜Squawk! Polly says: I want crackers!
🐠 Timmy says: Blub Blub! I make bubbles!

Listing all pet union case types:
--------------------------------
* Dog with props: Name
* Cat with props: NumberOfLives, Name
* Parrot with props: WantsCrackers, Name
* GoldFish with props: MakesBubbles, Name


Wednesday, 8 April 2026

C# 15 : Union types and the ApiResult Monad in .NET 11

Functional Programming in C# 15: Union Types and the ApiResult Monad

C# has been steadily absorbing ideas from functional programming — pattern matching, records, immutability. With C# 15, we get the feature that ties it all together: union types (discriminated unions). This post walks through why they matter, how they work, and how they enable a clean ApiResult<T> result monad that eliminates try/catch boilerplate and makes error handling composable.

All source code is available on GitHub: UnionTypesDemo1 and ApiResultMonad.


What Does Functional Programming Give Us?

Three things that make code dramatically easier to reason about:

  1. Totality — every function handles every possible input. No hidden exceptions, no nulls sneaking through.
  2. Composability — small pieces snap together into pipelines. You build complex behaviour by chaining simple transformations.
  3. Exhaustiveness — the compiler checks that you handled all cases. Forget one? It tells you at build time, not at 3 AM in production.

Union types are the mechanism that delivers all three in C#. Let’s start with a minimal example.


Union Types — The IntOrBool Example

A union type is a type that holds exactly one of several named cases at a time. No inheritance hierarchies, no object boxing, no OneOf<> libraries — just a closed set of possibilities known to the compiler.

Declaring the Union

public union IntOrBool(int i, bool b)
{
    public readonly bool AsBool() => this switch
    {
        int i  => i != 0,
        bool b => b,
        null   => throw new UnreachableException()
    };

    public readonly int AsInt() => this switch
    {
        int i  => i,
        bool b => b ? 1 : 0,
        null   => throw new UnreachableException()
    };

    public override string ToString() => this switch
    {
        int i  => $"Integer: {i}",
        bool b => $"bool: {b}",
        null   => throw new UnreachableException()
    };
}

One line — public union IntOrBool(int i, bool b); — declares a type that is either an int or a bool. Each member is a case. The compiler enforces exhaustiveness on every switch expression: drop a case and you get a warning (or an error).

Using It

IntOrBool intOrBool = 42;           // holds an int case
Console.WriteLine(intOrBool);               // "Integer: 42"
Console.WriteLine(intOrBool.AsBool());      // False  (0 == false, non-zero check)
Console.WriteLine(intOrBool.AsInt());       // 42

intOrBool = true;                   // reassigned — now holds a bool case
Console.WriteLine(intOrBool);               // "bool: True"
Console.WriteLine(intOrBool.AsBool());      // True
Console.WriteLine(intOrBool.AsInt());       // 1

No cast, no wrapper allocation. The implicit conversion handles it. A single variable can be reassigned across cases — the declared type stays IntOrBool; only the runtime case changes. This is what makes union types ergonomic compared to class hierarchies.

Pattern Matching

string Describe(IntOrBool value) => value switch
{
    int i  => $"It's an integer: {i}",
    bool b => $"It's a boolean: {b}",
    null   => throw new UnreachableException()
};

Exhaustiveness is checked statically. If you add a third case to the union later, every switch site that doesn’t handle it will fail to compile. That’s the kind of safety net you get from functional languages like F# and Rust — now native in C#.


What Is a Monad?

A monad is a design pattern from functional programming. Think of it as a smart wrapper around a value that lets you chain operations without checking for errors at every step. The wrapper carries the result or the failure through the pipeline — you only inspect the outcome at the end.

A monad needs three things:

  1. A wrapper type — something that contains a value (or an error). In our case: ApiResult<T>.
  2. A way to put a value in — often called return or unit. Here: ApiResult.Ok(value).
  3. A way to chain operationsBind (also known as flatMap). Given a wrapped value and a function that returns a new wrapped value, produce the next step in the pipeline.

Classic examples: Option<T> (value may be absent), Result<T, E> (success or error). ApiResult<T> is a result monad specifically tailored for HTTP calls.


The ApiResult<T> Union Type

Using C# 15 union types, we define a type that can be exactly one of three cases:

public record Success<T>(T Data);
public record HttpError(int StatusCode, string Message);
public record TransportError(Exception Exception);

public readonly union ApiResult<T>(
    Success<T> success,
    HttpError httpError,
    TransportError transportLevelError
);

CaseRepresents
Success<T>HTTP 2xx with a valid deserialized body
HttpErrorHTTP 4xx/5xx response
TransportErrorSocket, network, timeout, or other I/O exceptions

Every ApiResult<T> is exactly one of these three — no nulls, no exceptions leaking out, no forgetting to check response.IsSuccessStatusCode. The compiler won’t let you skip a case.


Map — Transform the Happy Path

Map applies a function to the inner value if it is a success. Errors pass through unchanged — you stay on the “happy rail” and errors propagate automatically. This is the functional alternative to writing if (result.IsSuccess) checks at every step.

public ApiResult<TResult> Map<TResult>(Func<T, TResult> f) => Value switch
{
    Success<T> s      => new Success<TResult>(f(s.Data)),
    HttpError h        => new HttpError(h.StatusCode, h.Message),
    TransportError t   => new TransportError(t.Exception),
    _                  => new HttpError(500, "Unhandled error")
};

Usage is clean:

ApiResult<string> title = ApiResult.Ok(todo)
    .Map(t => t.Title.ToUpperInvariant());

If todo was actually an HttpError or TransportError, the lambda is never invoked — the error flows through untouched. No if, no try/catch.


Bind — Chain Operations That Can Fail

Bind (a.k.a. flatMap) is for sequencing operations where the next step can itself fail. It unwraps the value and hands it to a function that returns a new ApiResult<TResult>, preventing the nested ApiResult<ApiResult<T>> problem that Map would produce.

public ApiResult<TResult> Bind<TResult>(Func<T, ApiResult<TResult>> f) => Value switch
{
    Success<T> s      => f(s.Data),
    HttpError h        => new HttpError(h.StatusCode, h.Message),
    TransportError t   => new TransportError(t.Exception),
    _                  => new HttpError(500, "Unhandled error")
};

Usage:

ApiResult<string> result = ApiResult.Ok(42)
    .Bind(id => id > 0
        ? ApiResult.Ok(id.ToString())
        : ApiResult.HttpFail<string>(HttpStatusCode.BadRequest, "Invalid id"));

Map vs Bind — At a Glance

OperationLambda signatureUse when
MapT → TResultTransforming data (can’t introduce new failures)
BindT → ApiResult<TResult>Next step can also fail

The GetJsonAsync Extension

The entry point into the monad is an extension method on HttpClient that wraps the entire HTTP call — success, HTTP errors, and transport exceptions — into an ApiResult<T>:

public static async Task<ApiResult<T>> GetJsonAsync<T>(
    this HttpClient httpClient, string url)
{
    try
    {
        using var response = await httpClient.GetAsync(url).ConfigureAwait(false);

        if (!response.IsSuccessStatusCode)
        {
            return new HttpError(
                (int)response.StatusCode,
                await response.Content.ReadAsStringAsync().ConfigureAwait(false));
        }

        await using var stream = await
            response.Content.ReadAsStreamAsync().ConfigureAwait(false);

        var val = await JsonSerializer.DeserializeAsync<T>(stream,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
            .ConfigureAwait(false);

        return val is not null
            ? new Success<T>(val)
            : ApiResult.HttpFail<T>(
                HttpStatusCode.UnprocessableEntity,
                $"No content or wrong content for type: {typeof(T).Name}");
    }
    catch (Exception ex)
    {
        return new TransportError(ex);
    }
}

No try/catch at the call site. Errors flow through the monad.


Chaining — and Why ContinueWith Is Not the Right Tool

The original example chains MapAsync and Bind like this:

var summary = await result
    .MapAsync(async todo => todo with { Title = todo.Title.ToUpperInvariant() })
    .ContinueWith(t => t.Result.Bind(todo =>
        todo.Completed
            ? ApiResult.Ok($"Done: {todo.Title}")
            : ApiResult.HttpFail<string>(HttpStatusCode.UnprocessableEntity, "Not completed")));

This works, but ContinueWith is a Task-level continuation — it belongs to TPL plumbing, not to monad composition. It has well-known pitfalls:

  • It does not capture SynchronizationContext by default (unlike await).
  • It swallows exceptions into AggregateException unless you explicitly unwrap.
  • It forces you to reach into t.Result, mixing two abstraction levels.

The cleaner fix is to add extension methods that operate on Task<ApiResult<T>> directly, so async and sync monadic operations compose seamlessly:

public static class ApiResultTaskExtensions
{
    /// <summary>
    /// Chains a synchronous Map on an async ApiResult pipeline.
    /// </summary>
    public static async Task<ApiResult<TResult>> MapAsync<T, TResult>(
        this Task<ApiResult<T>> task, Func<T, TResult> f)
    {
        var result = await task.ConfigureAwait(false);
        return result.Map(f);
    }

    /// <summary>
    /// Chains a synchronous Bind on an async ApiResult pipeline.
    /// </summary>
    public static async Task<ApiResult<TResult>> BindAsync<T, TResult>(
        this Task<ApiResult<T>> task, Func<T, ApiResult<TResult>> f)
    {
        var result = await task.ConfigureAwait(false);
        return result.Bind(f);
    }

    /// <summary>
    /// Chains an async Bind on an async ApiResult pipeline.
    /// </summary>
    public static async Task<ApiResult<TResult>> BindAsync<T, TResult>(
        this Task<ApiResult<T>> task, Func<T, Task<ApiResult<TResult>>> f)
    {
        var result = await task.ConfigureAwait(false);
        return await result.BindAsync(f).ConfigureAwait(false);
    }
}


Fluent Multi-Step Chaining

With those extensions in place, you can chain Map and Bind as many times as you want — all in a single fluent pipeline, no ContinueWith in sight:

var summary = await httpClient
    .GetJsonAsync<Todo>("https://jsonplaceholder.typicode.com/todos/4")
    // Step 1 (Map): uppercase the title
    .MapAsync(todo => todo with { Title = todo.Title.ToUpperInvariant() })
    // Step 2 (Map): prefix with ID
    .MapAsync(todo => todo with { Title = $"[{todo.Id}] {todo.Title}" })
    // Step 3 (Bind): fail if not completed, otherwise produce summary string
    .BindAsync(todo => todo.Completed
        ? ApiResult.Ok($"Completed: {todo.Title}")
        : ApiResult.HttpFail<string>(HttpStatusCode.BadRequest, "Not done yet"))
    // Step 4 (Map): final formatting
    .MapAsync(msg => $">> {msg} <<");

Console.WriteLine(summary.Value switch
{
    Success<string> s  => s.Data,
    HttpError h         => $"Error {h.StatusCode}: {h.Message}",
    TransportError t    => $"Transport: {t.Exception.Message}",
    _                   => "?"
});

If any step fails — say the HTTP call returns 404, or the Bind rejects a non-completed todo — every subsequent Map and Bind is skipped automatically. The error propagates through the pipeline untouched until you pattern-match at the end. This is the “railway-oriented programming” pattern: one rail for success, one for errors, and the switch track is Bind.

What About Multiple Async Steps?

The BindAsync overload that takes Func<T, Task<ApiResult<TResult>>> lets you chain operations that are themselves async and can fail — e.g., calling a second API based on the result of the first:

var enriched = await httpClient
    .GetJsonAsync<Todo>(url)
    .MapAsync(todo => todo with { Title = todo.Title.ToUpperInvariant() })
    .BindAsync(async todo =>
    {
        // Second HTTP call — also returns ApiResult<T>
        var userResult = await httpClient
            .GetJsonAsync<User>($"https://jsonplaceholder.typicode.com/users/{todo.UserId}");

        return userResult.Map(user => $"{user.Name}: {todo.Title}");
    });

Each step composes cleanly. The pipeline reads top-to-bottom exactly like the business logic it represents.


Consuming the Result

At the edge of your pipeline, use a switch expression to exhaustively handle all three outcome cases:

var output = result.Value switch
{
    Success<Todo> s    => $"OK: {s.Data.Title}",
    HttpError h         => $"HTTP {h.StatusCode}: {h.Message}",
    TransportError t    => $"Transport error: {t.Exception.Message}",
    _                   => "Unknown"
};

The compiler enforces exhaustiveness. You cannot forget a case.


Requirements & Setup

RequirementValue
IDEVS Code Insiders
ExtensionsC# Dev Kit + C# — both set to Pre-Release channel
Target frameworknet11.0 (Update 2)
Language version<LangVersion>preview</LangVersion> in .csproj

Union types are a C# 15 preview feature. The preview language version and preview extension channels are required for compiler support.


Key Takeaways

  1. Union types close the gap between C# and languages like F#/Rust. A single union keyword gives you a closed, exhaustive, pattern-matchable type.
  2. Monads make error handling composable. ApiResult<T> carries success or failure through a pipeline — no try/catch spaghetti, no null checks.
  3. Map and Bind are the building blocks. Map transforms data. Bind sequences operations that can fail. Together they give you railway-oriented programming in idiomatic C#.
  4. Avoid ContinueWith for monad chaining. Add Task<ApiResult<T>> extension methods instead. It keeps async and monadic composition at the same abstraction level and eliminates TPL pitfalls.
  5. Fluent pipelines read like business logic. Chain as many Map and Bind steps as you need. Errors propagate automatically — you only handle them once, at the end.

Source code: UnionTypesDemo1 (IntOrBool)ApiResultMonad