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:
- Totality — every function handles every possible input. No hidden exceptions, no nulls sneaking through.
- Composability — small pieces snap together into pipelines. You build complex behaviour by chaining simple transformations.
- 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:
- A wrapper type — something that contains a value (or an error). In our case:
ApiResult<T>. - A way to put a value in — often called
returnorunit. Here:ApiResult.Ok(value). - A way to chain operations —
Bind(also known asflatMap). 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
);
| Case | Represents |
|---|---|
Success<T> | HTTP 2xx with a valid deserialized body |
HttpError | HTTP 4xx/5xx response |
TransportError | Socket, 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
| Operation | Lambda signature | Use when |
|---|---|---|
Map | T → TResult | Transforming data (can’t introduce new failures) |
Bind | T → 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
SynchronizationContextby default (unlikeawait). - It swallows exceptions into
AggregateExceptionunless 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
| Requirement | Value |
|---|---|
| IDE | VS Code Insiders |
| Extensions | C# Dev Kit + C# — both set to Pre-Release channel |
| Target framework | net11.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
-
Union types close the gap between C# and languages like F#/Rust.
A single
unionkeyword gives you a closed, exhaustive, pattern-matchable type. -
Monads make error handling composable.
ApiResult<T>carries success or failure through a pipeline — notry/catchspaghetti, no null checks. -
Map and Bind are the building blocks.
Maptransforms data.Bindsequences operations that can fail. Together they give you railway-oriented programming in idiomatic C#. -
Avoid
ContinueWithfor monad chaining. AddTask<ApiResult<T>>extension methods instead. It keeps async and monadic composition at the same abstraction level and eliminates TPL pitfalls. -
Fluent pipelines read like business logic.
Chain as many
MapandBindsteps as you need. Errors propagate automatically — you only handle them once, at the end.
Source code: UnionTypesDemo1 (IntOrBool) • ApiResultMonad