Skip to content

Pattern Matching with Discriminated Unions in dotnet

Pawel Gerr edited this page Aug 24, 2025 · 1 revision

Pattern Matching with Discriminated Unions in .NET

Traditional C# pattern matching with switch statements and if/else chains is error-prone and doesn't guarantee exhaustive handling of all cases. When you add new types or states, it's easy to miss updating conditional logic, leading to runtime bugs. The library Thinktecture.Runtime.Extensions solves this with built-in Switch and Map methods for discriminated unions that enforce compile-time exhaustiveness checking.

Article series

  1. Discriminated Unions: Representation of Alternative Types in .NET
  2. Pattern Matching with Discriminated Unions in .NET ⬅

Estimated reading time: 12 min.

Introduction

In the previous article we introduced discriminated unions as a type-safe way to represent values that can be one of several distinct types, contrasting them with less safe alternatives like tuples or flag-based classes. A key benefit highlighted was the ability to perform exhaustive pattern matching, ensuring all possible cases of the union are handled.

This article dives deeper into the pattern matching capabilities provided by the library Thinktecture.Runtime.Extensions for discriminated unions. We'll explore the Switch and Map methods, understand their advantages over traditional C# pattern matching, examine advanced techniques, and see practical examples.

Technical Challenges with Handling Multiple Types

Working with types that represent alternatives often involves conditional logic to determine the actual underlying type or state before acting upon it. Traditional C# approaches present challenges:

  • If/else-if chains with type checks: Verbose, error-prone, and doesn't guarantee all types are checked.
  • Switch statements on type: Better, but C# doesn't inherently enforce that all possible derived types or states are covered without explicit default case that might hide errors.
  • Forgetting Cases: When a new type or state is added to the set of alternatives, it's easy to miss updating all the conditional logic locations, leading to runtime errors or unexpected behavior.

Discriminated unions, combined with dedicated pattern matching methods, solve these issues by leveraging the type system to enforce completeness.

From Basic Pattern Matching to Rich, Exhaustive Switches

Consider a typical C# switch expression for handling a result type implemented via inheritance:

public abstract record Result<T>
{
    public record Success(T Value) : Result<T>;
    public record Failure(string Error) : Result<T>;
}

The main issue is the lack of compile-time exhaustiveness checking without relying on a default case. If a new Result type (like Cancelled) is added, the compiler doesn't force you to update the switch.

public string HandleResult<T>(Result<T> result)
{
    // Traditional switch expression
    return result switch
    {
        Result<T>.Success s => $"Success: {s.Value}",
        Result<T>.Failure f => $"Error: {f.Error}",

        // ⚠️️ If we add new derived type 'Cancelled', the compiler WON'T warn us
        // that this switch is no longer exhaustive without a default case.

        _ => "Unknown result" // A default case is often needed, hiding potential bugs
    };
}

The library Thinktecture.Runtime.Extensions provides Switch and Map methods directly on the generated union types, guaranteeing exhaustiveness at compile time.

Built-in Pattern Matching with Switch/Map

The library generates methods Switch (for performing actions) and Map (for mapping values) for both ad hoc and regular unions.

Example using the ad hoc union TextOrNumber:

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

// Usage
TextOrNumber textOrNum = 42;

// Switch (Action-based)
textOrNum.Switch(
    @string: text => Console.WriteLine($"Text: {text}"),
    int32: num => Console.WriteLine($"Number: {num}")
);

// Switch (Func-based)
string description = textOrNum.Switch(
    @string: text => $"The text is '{text}'",
    int32: num => $"The number is {num}"
);

By default, Switch/Map methods have parameters named after the types (e.g., @string, int32, boolean). Use @ prefix if the type name is a C# keyword or rename the properties.

With regular unions, the parameters are named after the nested derived types (e.g., success, failure).

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

// Usage
Result<string> opResult = new Result<string>.Success("Data loaded");

// Switch (Action-based)
opResult.Switch(
    success: s => Console.WriteLine($"Success: {s.Value}"),
    failure: f => Console.WriteLine($"Failure: {f.Error}")
);

// Map (directly maps cases to values, no callbacks)
bool wasSuccessful = opResult.Map(
    success: true,
    failure: false
);

Key Advantages:

  • Exhaustiveness: The compiler requires you to provide a handler for every defined case in the union. If you add a new case to the union definition (e.g., add bool to TextOrNumber, or Cancelled to Result<T>), any Switch or Map call that doesn't handle the new case will produce a compile-time error.
  • Type Safety: The parameter passed to each callback is strongly typed to the specific case it handles (e.g., text is string, num is int, s is Result<T>.Success). No casting is needed.
  • Readability: Clearly expresses the handling for each alternative.

Performance Optimizations: Like Smart Enums, the methods Switch and Map have overloads that accept an additional state/context parameter to avoid closures and associated allocations, which can be beneficial in performance-sensitive code.

ILogger logger = /* ILogger instance */;

opResult.Switch(
    logger, // Pass logger as state
    success: static (log, s) => log.LogInformation("Success: {Value}", s.Value), // Static lambda
    failure: static (log, f) => log.LogError("Failure: {Error}", f.Error)        // Static lambda
);

Partial Pattern Matching

By setting SwitchMethods/MapMethods to DefaultWithPartialOverloads, the library generates new method(s): SwitchPartially/MapPartially. These require a @default handler but allow you to omit handlers for specific cases if needed.

⚠️️ Use SwitchPartially/MapPartially cautiously, as it sacrifices compile-time exhaustiveness checking for the omitted cases.

// Ad hoc union configured for partial pattern matching
[Union<string, int, bool>(SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial class TextOrNumberOrBool;

TextOrNumberOrBool value = true;

// Handles 'string' explicitly, uses default for 'int' and 'bool'
value.SwitchPartially(
    @string: text => Console.WriteLine($"Specific handling for string: {text}")
    @default: obj => Console.WriteLine($"Default handling for: {obj} ({obj?.GetType().Name})"),
);

Ad Hoc Union Example: API Response Handling

Handling different types of API responses is a common scenario well-suited for ad hoc unions, as the response types often already exist and don't share a common class hierarchy.

// Existing response types
public record ProductDto(int Id, string Name, decimal Price);
public record ApiError(int StatusCode, string Message);
public record ValidationFailure(string Field, string Message);

// Ad hoc union combining the possible response types
[Union<ProductDto, ApiError, List<ValidationFailure>>]
public partial class GetProductResponse;

The method GetProductById returns one of three response types that are implicitly converted to GetProductResponse.

public GetProductResponse GetProductById(int id)
{
   return id switch
   {
      // Return validation errors
      <= 0 => new List<ValidationFailure> { new("id", "ID must be positive.") },

      // Return data
      1 => new ProductDto(1, "Laptop", 1200.00m),

      // Return a specific API error (e.g., Not Found)
      // 📝 Alternatively, create a specific type for "NotFound"
      < 100 => new ApiError(404, $"Product with ID {id} not found."),

      // Return a generic server error
      _ => new ApiError(500, "An internal server error occurred.")
   };
}

A Web API controller (or Minimal API endpoint) can use the method Switch to transform the GetProductResponse to an IActionResult.

public IActionResult HandleApiResponse(int productId)
{
    GetProductResponse response = GetProductById(productId);

    // Use Switch to convert the union into an IActionResult
    return response.Switch<IActionResult>(
        productDto: product => Ok(product), // HTTP 200 OK with product data
        apiError: error => StatusCode(error.StatusCode, error.Message), // HTTP 404 or 500
        listOfValidationFailure: errors => BadRequest(errors) // HTTP 400 Bad Request
    );
}

Regular Union Example: Workflow State Management

Regular unions shine when modeling states within a domain entity, where each state might have different associated data. Let's explore the OrderState example to see how the Switch and Map methods facilitate more complex state-dependent logic and transitions, using slightly enhanced state data.

[Union]
public abstract partial record OrderState
{
    // States defined as nested records inheriting from OrderState
    public sealed record New(string CreatedBy) : OrderState;
    public sealed record Processing(DateTime StartedAt, string ProcessedBy) : OrderState;
    public sealed record Shipped(DateTime ShippedAt, string TrackingNumber, string Carrier)
        : OrderState;
    public sealed record Cancelled(DateTime CancelledAt, string Reason, decimal? CancellationFee)
        : OrderState;
}

This OrderState union models the lifecycle of an e-commerce order, where each state carries data specific to that stage of the process. Notice how each state record contains contextually relevant information – New tracks who created the order, Processing includes timing and assignment details, Shipped contains tracking information, and Cancelled records the reason and any associated fees.

Now let's see how to use this union in a practical scenario with an Order class that manages state transitions and implements state-dependent behavior using the Switch method:

// Placeholder for permissions check
public record UserPermissions(bool CanShipOrder);

public class Order
{
   private readonly List<OrderState> _states;

   public int Id { get; }
   public OrderState CurrentState => _states[^1]; // Get the last state in the list

   public Order(int id, string createdBy)
   {
      Id = id;
      _states = [new OrderState.New(createdBy)]; // Initial state
   }

   // Method for state transition
   public bool Ship(string trackingNumber, string carrier, UserPermissions userPermissions)
   {
      // Use Switch to determine the outcome
      return CurrentState.Switch(
         processing: _ =>
         {
            // Check permission
            if (!userPermissions.CanShipOrder)
               return false; // User does not have permission to ship this order

            // Change state
            _states.Add(new OrderState.Shipped(DateTime.UtcNow, trackingNumber, carrier));
            return true;
         },
         @new: static _ => false,       // Order must be processed before shipping
         shipped: static _ => false,    // Order has already been shipped
         cancelled: static _ => false); // Cannot ship a cancelled order
   }

   // Gets summary string
   public string GetStatusSummary()
   {
      return CurrentState.Switch(
         @new: static state => $"Order created by {state.CreatedBy}.",
         processing: static state => $"Order processing since {state.StartedAt}.",
         shipped: static state => $"Order shipped on {state.ShippedAt} via {state.Carrier}.",
         cancelled: static state => $"Order cancelled on {state.CancelledAt}: {state.Reason}"
      );
   }
}

This example demonstrates how the Switch method provides compile-time safety by ensuring all states are handled, while giving type-safe access to each state's specific data. The result is clear, maintainable business logic that eliminates the risk of unhandled cases when new states are added to the union.

Summary

Discriminated unions provide a robust and type-safe mechanism for handling alternative types or states within your code. A key advantage lies in the pattern matching capabilities offered by methods Switch and Map. These methods guarantee compile-time exhaustiveness checking, effectively preventing bugs that arise from unhandled cases – a significant improvement over traditional C# switch statements or if/else type checks. Both ad hoc and regular unions benefit from these powerful pattern matching features. For performance-critical code, remember to utilize the overloads that accept static methods and context parameters to avoid unnecessary allocations.

In the next article, we'll explore how discriminated unions fit into Domain-Driven Design, particularly for modeling domain states and variants effectively.

Clone this wiki locally