Discriminated Unions - Part 3 - Practical Uses
[Programming, Design, Domain Modeling, C#, .NET]
This is Part 3 of a series on Discriminated Unions.
- Part 1 - Introduction
- Part 2 - Implementation
- Part 3 - Practical Uses (this post)
- Part 4 - .NET 11 Preview - Discriminated Unions Support
In our previous post, we looked at how to implement discriminated unions in a way to support a model of payments, where a PaymentProcessor can handle different types of payments.
In this post, we will look at an elegant solution to handle a common problem - implementing an endpoint that fetches an entity by ID.
We will implement this in a simple ASP.NET project.
First, a simple type to hold our entity, a Person.
public sealed record Person(string FirstName, string LastName, int ID, bool Active);
Then, our minimal API to search this.
var builder = WebApplication.CreateBuilder(args);
// Create a dummy 'database' in memory
List<Person> people =
[
new Person("James", "Bond", 1, true),
new Person("Evelyn", "Salt", 2, true),
new Person("Jason", "Bourne", 3, true),
new Person("Modesty", "Blaise", 4, false),
new Person("Harry", "Pearce", 5, true),
];
// Register database
builder.Services.AddSingleton(people);
var app = builder.Build();
// Setup end point
app.MapGet("/Get/{id:int}", (List<Person> people, int id) =>
{
var person = people.SingleOrDefault(x => x.ID == id);
// If person is not found, return Http 401
if (person is null)
{
return Results.NotFound();
}
// Return found person
return Results.Ok(person);
});
app.Run();
We can then test this out, using Yaak, my tool of choice:
If successful:

If unsuccessful:

Here, there are two possibilities:
- We found our
Person - We didn’t find our
Person
There is a bunch of other possibilities:
- The
Personwas found, but is inactive - There was a database error of some sort
- There is a network error of some sort
In other words, there are other outcomes, some that are relevant to the problem domain, and those that are not.
We can express our possibilities as follows:
- Found, and active - the success path
- Found, but not active
- Not Found
- Some sort of error occurred
This is a good candidate to use discriminated unions.
We can model the following additional types:
// Type to express no result was returned
public sealed record NotFound;
// Type to express Person was found, but inactive
public sealed record FoundInactive(Person person);
// Type to express some sort of problem was encountered
public sealed record Problem(string Details);
We then introduce a service to which we delegate the fetch:
public sealed class Searcher
{
public static OneOf<Person, FoundInactive, NotFound, Problem> Find(List<Person> people, int id)
{
try
{
// Randomly throw an exception
if (Random.Shared.Next(0, 2) < 1)
return new Problem("Random error");
// Try and find the person
var person = people.SingleOrDefault(x => x.ID == id);
// Not found
if (person is null)
return new NotFound();
// Found, but with caveats
return person.Active switch
{
// Active, normal result
true => person,
// Inactive, edge case result
false => new FoundInactive(person)
};
}
catch (Exception e)
{
// Some other exception. Return this too
return new Problem(e.Message);
}
}
}
In our Searcher, we are making use of the OneOf package to have a discriminated union as a return type, where we return each of the possible scenarios.
Finally, our API simply matches against the return types:
// Setup endpoint
app.MapGet("/Get/{id:int}", (List<Person> injectedPeople, int id) =>
{
return Searcher.Find(injectedPeople, id).Match(
person => Results.Ok(person),
inactive => Results.UnprocessableEntity(inactive),
notFound => Results.NotFound(),
problem => Results.Problem(problem.Details)
);
});
Here we can see that the API code is very minimal - just choosing how to render the response depending on the returned type.
This can be further simplified as follows:
// Setup endpoint
app.MapGet("/Get/{id:int}", (List<Person> injectedPeople, int id) =>
{
return Searcher.Find(injectedPeople, id).Match(
Results.Ok,
Results.UnprocessableEntity,
_ => Results.NotFound(),
problem => Results.Problem(problem.Details)
);
});
But I find this difficult to read. I prefer the former, more explicit expression.
Now, expect exactly 1 of the 4 possible results:




With this design, you are forced to decide up front the return types you are expecting, and write your code around these options.
TLDR
Discriminated unions lend themselves well to domain design, where we come up with the possible states we are expecting from our system upfront.
The code is in my GitHub.
Happy hacking!