🧠 What Are Discriminated Unions?
A discriminated union is a type that can represent one of several distinct outcomes, but never more than one at a time. They are native to F#, Rust, TypeScript, and other functional languages.
In C#, we simulate this using the excellent OneOf
library.
🧼 Why Classic C# Fails Here
Pattern | Why it's bad |
---|---|
null |
No context — what went wrong? |
bool TryX(out T) |
Hard to read, hard to test |
try/catch as logic |
Expensive, messy, and often misused |
Exception everywhere | Breaks flow, pollutes logic |
✅ Why OneOf Is Better
- Type-safe return values
- Explicit control over outcomes
- Functional-style
.Match(...)
- Easier to test, reason about, and maintain
- Avoids throwing exceptions for flow control
🛠 Setup
dotnet new azure-functions -n OneOfDemo --worker-runtime dotnetIsolated
cd OneOfDemo
dotnet add package OneOf
📦 Define Your Result Types
public record User(int Id, string Name);
public record NotFoundError(string Message);
public record TimeoutError(string Message);
public record ValidationError(string Message);
public record UnknownError(string Message);
🔧 Build the Service Layer
public class UserService
{
private readonly HttpClient _http = new()
{
BaseAddress = new Uri("https://jsonplaceholder.typicode.com")
};
public async Task<OneOf<User, NotFoundError, TimeoutError, UnknownError, ValidationError>> GetUserByIdAsync(int id)
{
if (id <= 0)
return new ValidationError("Id must be greater than 0.");
if (id == 99)
return new TimeoutError("Simulated timeout.");
if (id == 42)
return new UnknownError("Simulated crash.");
try
{
var response = await _http.GetAsync($"/users/{id}");
if (!response.IsSuccessStatusCode)
return new NotFoundError("User not found.");
var user = await response.Content.ReadFromJsonAsync<User>();
return user ?? new NotFoundError("User not found.");
}
catch (Exception ex)
{
return new UnknownError($"Unexpected error: {ex.Message}");
}
}
}
🚀 Azure Function Using Match
[Function("GetUserById")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "users/{id:int}")] HttpRequestData req,
int id)
{
var result = await _service.GetUserByIdAsync(id);
return await result.Match<Task<HttpResponseData>>(
async user => req.CreateJsonResponse(HttpStatusCode.OK, user),
async notFound => req.CreateJsonResponse(HttpStatusCode.NotFound, new { error = notFound.Message }),
async timeout => req.CreateJsonResponse(HttpStatusCode.RequestTimeout, new { error = timeout.Message }),
async unknown => req.CreateJsonResponse(HttpStatusCode.InternalServerError, new { error = unknown.Message }),
async validation => req.CreateJsonResponse(HttpStatusCode.BadRequest, new { error = validation.Message })
);
}
🧼 Extension Method
public static class HttpResponseExtensions
{
public static async Task<HttpResponseData> CreateJsonResponse<T>(this HttpRequestData req, HttpStatusCode status, T body)
{
var response = req.CreateResponse(status);
await response.WriteAsJsonAsync(body);
return response;
}
}
🧪 Unit Testing With OneOf
[Fact]
public async Task Returns_ValidationError_When_Id_Is_Zero()
{
var service = new UserService();
var result = await service.GetUserByIdAsync(0);
Assert.True(result.IsT4); // ValidationError
Assert.Equal("Id must be greater than 0.", result.AsT4.Message);
}
You can test any path: .IsT0
, .IsT1
, .IsT2
... depending on position in OneOf.
🧠 Benefits of This Approach
- No need for null checks
- No exception-based flow
- Clear separation of responsibilities
- Total control over error behavior
- Greatly improved testability
🧨 Common Mistakes
Mistake | What to do instead |
---|---|
Returning null | Return a specific error type |
Throwing errors everywhere | Return values like TimeoutError
|
Using try/catch for flow | Use .Match(...) and OneOf types |
Skipping tests for errors | Test each OneOf case with .IsTn
|
📐 Architecture Tips
- Put all error types in an
Errors/
folder - Use
record
for immutability and clarity - Use
OneOf
everywhere instead of throwing - Keep Azure Function logic lean — let services do the work
🔚 Final Thoughts
This pattern is not just syntactic sugar — it changes how you think about control flow, responsibility, and correctness.
Using OneOf + Azure Functions:
- You get full control of your return paths
- You write less error-prone code
- You make every function explicit and testable
And it still feels like C#.
📚 Resources
- OneOf GitHub: https://github.com/mcintyre321/OneOf
- Example code: https://github.com/Mumma6/azure-function-oneof