🧠 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
Top comments (0)