🧠 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