DEV Community

Cover image for Discriminated Unions in C# with OneOf – Azure Functions
Martin Persson
Martin Persson

Posted on

Discriminated Unions in C# with OneOf – Azure Functions

🧠 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 
Enter fullscreen mode Exit fullscreen mode

📦 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); 
Enter fullscreen mode Exit fullscreen mode

🔧 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}"); } } } 
Enter fullscreen mode Exit fullscreen mode

🚀 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 }) ); } 
Enter fullscreen mode Exit fullscreen mode

🧼 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; } } 
Enter fullscreen mode Exit fullscreen mode

🧪 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); } 
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)