Skip to content

Discriminated Unions Representation of Alternative Types in dotnet

Pawel Gerr edited this page Jul 5, 2025 · 1 revision

Discriminated Unions: Representation of Alternative Types in .NET

Estimated reading time: 14 min.

Introduction

In software development, a common challenge is representing a value that could logically be one of several distinct types or states. For instance, an operation might succeed, returning data, or fail, returning an error message. How can we model such alternatives in C# without sacrificing type safety or clarity?

Representing these alternatives using conventional C# techniques can often lead to runtime errors, cumbersome type checks, and code that's difficult to maintain. This article introduces Discriminated Unions, a powerful pattern for modeling these "one-of" scenarios in a type-safe, expressive, and robust manner. We'll first examine some common alternative approaches and their pitfalls, then explore how discriminated unions provide a superior solution, helping prevent common bugs, clarify domain logic, and how they can be implemented effectively in .NET using the library Thinktecture.Runtime.Extensions.

Providing solutions via a .NET library

The library Thinktecture.Runtime.Extensions was created to address a fundamental challenge in modern C# development: providing robust, type-safe domain modeling without excessive boilerplate code. Commonly, developers create complex conditional logic, rely on runtime type checking, or misuse exceptions to control program flow when dealing with different variants of a concept. These approaches result in error-prone code that's difficult to maintain, extend, and reason about.

With the introduction of Roslyn Source Generators in .NET 5 (2020), new possibilities opened up that were previously unattainable. Before this technology, implementing discriminated unions in a clean, maintainable way typically required extensive boilerplate code or generic base classes. While the latter approach was fully functional, it suffered from a lack of semantics.

Building on the success of the Value Object implementation in the library, discriminated unions were added in 2024. Unlike value objects which focus on validation, discriminated unions address a different but equally important concern: properly modeling alternatives within a single type. The feature was quickly adopted in multiple projects almost immediately after implementation, demonstrating the pressing need for this pattern in real-world applications and validating its design and usability.

Roslyn Source Generators are a feature of the .NET Compiler Platform (Roslyn) that allow the developers to generate code at compile time. The source generators can read existing C# code and other files, analyze them, and then produce new C# code that is added to the compilation.

Common Approaches and Their Limitations

Before exploring discriminated unions, let's examine some frequently used techniques in C# for handling operations that might return different kinds of results, such as successfully retrieving data or encountering an error. While functional, these approaches often introduce complexities and potential pitfalls.

Returning Tuples

A common pattern involves a method returning tuples or records.

// Expected return type on success
public record Order(int Id, decimal TotalAmount);

// Method returning a tuple
public (Order? Order, bool IsSoftDeleted, string? Error) GetOrder(int orderId)
{
    if (orderId <= 0)
        return (null, false, "Order ID must be positive."); // Input validation error

    OrderFromDb? orderFromDb = LoadOrderFromDb(orderId);

    if(orderFromDb is null)
        return (null, false, $"Order with ID {orderId} not found.");

    if(orderFromDb.IsDeleted)
        return (null, true, null); // soft-deleted

    var order = new Order(orderFromDb.Id, orderFromDb.TotalAmount);

    return (order, false, null); // success
}

The method GetOrder returns a tuple with 3 properties indicating success, soft-delete, or an error. The corresponding usage could be as follows:

(Order? order, bool isSoftDeleted, string? error) = GetOrder(123);

if (error is not null)
{
    // handle error
}
else if (isSoftDeleted)
{
    // handle soft-delete
}
else if (order is not null)
{
    // process order
}
else
{
    // Unexpected state: error is null, order is null, order is not soft deleted.
}

Challenges with this approach:

  • Potential for Invalid States: The type system doesn't prevent states where order, error are null and isSoftDeleted is false, or potentially order is non-null and isSoftDeleted is true. Such combinations usually represent invalid or unexpected states.
  • Reliance on Conventions: Correct interpretation hinges on the convention that a non-null error indicates failure. This agreement isn't enforced by the compiler.
  • Scalability Issues: As the number of possible outcomes or error types increases, managing them within a single tuple becomes progressively more cumbersome.

Using Generics and Flags (Result)

Another common attempt to improve type safety involves using generics.

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; } // commonly used with a guard clause checking IsSuccess
    public string? Error { get; }

    private Result(bool isSuccess, T? value, string? error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) => new Result<T>(true, value, null);
    public static Result<T> Failure(string error) => new Result<T>(false, default, error);
}

The generic class has two states: success or error. To get more distinct states, like soft-deleted, we have to either replace string? Error with another generic TError or, again, use tuples for T (i.e. (Order, bool)).

And the corresponding usage of this approach:

public Result<Order> GetOrder(int orderId) { /*... */ }

Result<Order> orderResult = GetOrder(42);

// Check status before accessing value
if (orderResult.IsSuccess)
{
    // handle orderResult.Value
}
else
{
    // handle orderResult.Error
}

Drawbacks of this approach:

  • Lack of Compile-Time Checks: The compiler does not prevent accessing Value when IsSuccess is false, or accessing Error when IsSuccess is true.
  • Reliance on Runtime Checks: Correct usage relies entirely on runtime checks, leaving room for errors like NullReferenceException if checks are forgotten or incorrect.
  • Nullability Issues: Requires careful handling of Value's nullability, especially if T could be a reference type or Nullable<T>.
  • No Exhaustiveness Guarantee: The compiler doesn't ensure that all possible states (like IsSuccess = true and IsSuccess = false) are handled in consuming code. This problem becomes significantly worse if the "status flag" is an enumeration with multiple values instead of a boolean.

Using Exceptions

Probably the most common approach in C# for handling operations that might have different outcomes is to use exceptions for error cases.

public Order GetOrder(int orderId)
{
    if (orderId <= 0)
        throw new ArgumentException("Order ID must be positive.", nameof(orderId));

    OrderFromDb? orderFromDb = LoadOrderFromDb(orderId);

    if(orderFromDb is null)
        throw new RecordNotFoundException($"Order with ID {orderId} not found.");

    if(orderFromDb.IsDeleted)
        throw new RecordSoftDeletedException("Order is soft deleted.");

    return new Order(orderFromDb.Id, orderFromDb.TotalAmount);
}

When calling the method, the caller must handle each possible exception type in separate catch blocks:

try
{
    Order order = GetOrder(42);
    // process order
}
catch (ArgumentException ex)
{
    // handle intput validation error
}
catch (RecordNotFoundException ex)
{
    // handle order not found
}
catch (RecordSoftDeletedException ex)
{
    // handle order soft-deleted
}
catch (Exception ex)
{
    // handle other errors
}

Drawbacks of this approach:

  • Control Flow via Exceptions: Using exceptions for expected outcomes (like "not found") rather than truly exceptional cases can lead to performance overhead and obscures the normal flow of execution.
  • Reliance on Documentation: The caller needs to know which exceptions a method might throw. Unlike return types, exceptions aren't part of the method signature, so they're not enforced by the compiler.
  • Exception Handling Complexity: As the number of possible failures increases, the try/catch blocks become unwieldy with multiple catch clauses, making it easy to miss handling a specific exception type.

While these patterns are widespread, they often result in code that depends heavily on runtime checks, lacks compile-time guarantees about handling all outcomes, and can obscure the specific reason for a failure. Discriminated unions provide a mechanism to model these alternative outcomes directly within the type system, leading to increased safety and clarity.

Implementing Discriminated Unions in C#

Discriminated unions are types designed to hold a value that can be exactly one of several predefined, distinct possibilities. They combine the flexibility of representing alternatives with the safety of the C# type system.

It's worth noting that there is an active proposal to introduce native discriminated union types directly into the C# language. However, the timeline for this feature's implementation is currently uncertain, and solutions like the one presented in this article provide the benefits today.

Key characteristics include:

  • Type Safety: Each possible case within the union can hold data of a specific, known type. Accessing the data is type-safe, eliminating the need for risky casts.
  • Exhaustive Pattern Matching: Discriminated unions enable pattern matching constructs (like Switch methods or expressions) where the compiler can verify that all possible cases are handled. This prevents bugs caused by forgetting to handle a specific state or type.
  • Self-Validation / No Invalid States: By design, a discriminated union instance can only represent one of its defined cases at any time. It's impossible to construct an instance that is simultaneously a "Success" and a "Failure", or neither.
  • Clear Domain Modeling: Discriminated unions make the different states or possibilities explicit in the type definition, improving code readability and communicating intent clearly.

The library Thinktecture.Runtime.Extensions leverages Roslyn Source Generators to make implementing discriminated unions straightforward, removing the need for manual boilerplate code. The library offers two main kinds of discriminated unions.

Ad hoc Unions

Ad hoc unions are defined using the [Union<T1, T2, ...>] attribute on a partial class, struct or ref struct. This approach is ideal for combining a fixed set of existing, unrelated types into a single union type without requiring them to share a common base class.

// Combining string and int
[Union<string, int>]
public partial class TextOrNumber;

Regular Unions

Regular unions are defined using the [Union] attribute on an partial class or record. Each possible case is then represented by a nested class inheriting directly or indirectly from this base class. This approach is well-suited for scenarios where the different cases represent related concepts, potentially with their own distinct properties and structure, all belonging to the same logical group.

// Base class for different payment details
[Union]
public partial class PaymentDetails
{
   public sealed class CreditCard : PaymentDetails { /* Credit card specific properties */ }
   public sealed class BankTransfer : PaymentDetails { /* Bank transfer specific properties */ }
}

Refactoring Result with Discriminated Unions

Now, let's refactor our Result<T> example using the Regular Union pattern. This pattern is well-suited here because success and failure are conceptually related outcomes of a single operation, and the pattern works well with generic types.

[Union]
public partial record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record Failure(string Error) : Result<T>;

    // 📝 Add another type for soft-deleted entities if needed
    // public sealed record SoftDeleted : Result<T>;
}

The return type of GetOrder() remains the same, but the creation of the instances is simpler due to implicit conversions from T to Result<T>.Success and from string to Result<T>.Failure, if not disabled.

📝 Recommended: Use specific type to hold error details instead of string, otherwise it gets odd with Result<string>.

public record Order(int Id, decimal TotalAmount);

public Result<Order> GetOrder(int orderId)
{
   if (orderId <= 0)
      return "Order ID must be positive."; // Implicit conversion to Result<Order>.Failure

   OrderFromDb? orderFromDb = LoadOrderFromDb(orderId);

   if(orderFromDb is null)
       return $"Order with ID {orderId} not found."; // Result<Order>.Failure

   return new Order(orderFromDb.Id, orderFromDb.TotalAmount); // Result<Order>.Success
}

Use the generated method Switch for type-safe, exhaustive handling:

Result<Order> result = GetOrder(123);

result.Switch(
    success: success => Handle(success.Value),
    failure: failure => Handle(failure.Error)
);

Benefits demonstrated:

  • Type Safety: The callback parameters inside the method Switch are strongly typed, no casting needed.
  • Exhaustiveness: The Switch method requires handlers for all nested types derived from Result<T> (Success and Failure). If you forget one, you get a compile-time error, preventing unhandled cases.
  • No Invalid States: An instance of Result<Order> is guaranteed to be either a Success or a Failure, never both or neither. The invalid states possible with tuples or flag-based classes are eliminated by the type system.
  • Clarity: The code clearly expresses the possible outcomes directly in the type definition and usage.

Smart Enums vs. Discriminated Unions: Choosing the Right Tool

Both, Smart Enums (covered in the article Smart Enums: Beyond Traditional Enumerations in .NET) and Discriminated Unions are powerful patterns for modeling variations within a domain. While they share similarities, they excel in different scenarios:

  • Smart Enums are ideal for representing a fixed, known set of distinct instances of a concept, often with associated data or behavior that varies per instance. Think of states (like order status), categories, types, or strategies where the set of possibilities is predefined. The focus is on the identity and properties/behavior of each specific instance. Each instance is of the same type.

    (⚠️️ While the implementation may use different concrete types internally to provide instance-specific behavior.)

  • Discriminated unions are best suited for modeling a situation where a value can be one of several different shapes or structures. The focus is on the structure of the data itself, which varies depending on the case. Think of results (success/failure) or payment details (credit card/bank transfer). Each case can be a different type or structure.

Often, these patterns complement each other effectively within the same domain model. Consider processing different types of messages having two different outcomes:

[Union]
public abstract partial record ProcessingResult
{
    public sealed record Success(string TransactionId, DateTime Timestamp) : ProcessingResult;
    public sealed record Failure(string ErrorMessage) : ProcessingResult;
}

The discriminated union ProcessingResult defines two possible outcomes of processing (success or failure), while the Smart Enum MessageProcessorType (shown below) defines the type of processor to use (Email or SMS).

[SmartEnum<string>]
public partial class MessageProcessorType
{
    public static readonly MessageProcessorType Email = new("EMAIL", ProcessEmail);
    public static readonly MessageProcessorType Sms = new("SMS", ProcessSms);

    [UseDelegateFromConstructor]
    public partial ProcessingResult Process(string message); // Returns a discriminated union

    // Dummy processing logic
    private static ProcessingResult ProcessEmail(string message)
    {
        if (ContainsInvalidCharacter(message))
            return "Email processing failed."; // Failure

        return new ProcessingResult.Success("123", DateTime.UtcNow); // Success
    }

    private static ProcessingResult ProcessSms(string message)
    {
        return new ProcessingResult.Success("123", DateTime.UtcNow); // Success
    }
}

Both types in action, the Smart Enum selects the strategy, while the discriminated union models the structure of the outcome.

var processorType = MessageProcessorType.Email;

ProcessingResult result = processorType.Process("Your order shipped!");

// Handle result
result.Switch(
    success: success => Handle(success.TransactionId, success.Timestamp),
    failure: failure => Handle(failure.ErrorMessage)
);

Identifying Discriminated Union Candidates in Your Code

Look for these patterns in your codebase that might benefit from discriminated unions:

  • Classes containing "type flags" or enums used to determine how to interpret other properties (e.g., a Status enum alongside Value and ErrorMessage properties).
  • Extensive use of switch statements or if/else if/else chains based on an object's runtime type or a status flag.
  • Base classes where derived classes mainly differ by the data they hold for a specific state.
  • Methods returning complex tuples or custom classes with multiple nullable properties intended to represent mutually exclusive outcomes.

Summary

Discriminated unions offer a robust and elegant way to handle alternative types and states in C#. By leveraging the type system and enabling exhaustive pattern matching, they eliminate entire classes of bugs common with more primitive approaches like tuples with nullable fields or classes with status flags. While requiring a slight shift in thinking, adopting discriminated unions, especially with the help of the library Thinktecture.Runtime.Extensions which automates the boilerplate, can significantly improve the safety, clarity, and maintainability of your .NET applications.

In the next article in this series, we'll dive deeper into pattern matching with discriminated unions. We'll explore different ways to use the Switch methods, handle nested discriminated unions, and apply these techniques to solve more complex real-world problems effectively.

Clone this wiki locally