DEV Community

max-arshinov
max-arshinov

Posted on

F# Discriminated Unions vs. C# Class Hierarchies: A Design Perspective

Let's expand on the previous User class example. Let our user be a customer of an online store.

Once a customer finalizes an order, any post-dispatch modifications risk integrity breaches. It's essential to ensure the order data's privacy and integrity.

public class User
{
    // ...

    public IEnumerable<Order> Orders { get; init; }
}

public partial class Order
{
    public bool IsValidated { get; init; }
    public decimal? Price { get; init; }
    public IEnumerable<OrderItem> OrderItems { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

For instance, consider the typical procedures related to an order: verifying warehouse inventory, processing shipments, and handling cancellations.

Is it necessary to confirm inventory before processing payment?

That hinges on specific business rules.

public partial class Order
{
    // Validated
    public bool IsValidated { get; init; }
    public decimal? Price { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Can we ship an order prior to receiving payment?

Some retailers permit payment upon delivery, especially for local shipments.

public partial class Order
{
    // Shipped
    public bool IsShipped { get; init; }
    public string? TrackingUrl { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

And what if a delivered order needs to be canceled?

Perhaps our system requires a more nuanced status than merely "canceled." A "dispute" status might be more apt, prompting further inquiries: Was the item damaged en route? Was there an error at the warehouse? Or did the customer mistakenly receive what they actually ordered?

public partial class Order
{
    // Cancelled
    public bool IsCancelled { get; init; }
    public string? CancellationReason { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Problem statement

public class User
{
    // ...

    public IEnumerable<Order> Orders { get; init; }
}

public class Order
{
    // Validated
    public bool IsValidated { get; init; }
    public decimal? Price { get; init; }
    public IEnumerable<OrderItem> OrderItems { get; init; }

    // Shipped
    public bool IsShipped { get; init; }
    public string? TrackingUrl { get; init; }

    // Cancelled
    public bool IsCancelled { get; init; }
    public string? CancellationReason { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

The intricacies related to specific order states are only meaningful within their respective contexts. In C#, the class design might not always explicitly indicate the relationship between an order's status and its content. This singular class attempts to capture diverse order states, leading to potential ambiguities. How can we better encapsulate these states and the associated business logic?

Discriminated Unions

F# offers discriminated unions (DU) to address such challenges:

type ValidatedOrder = {
    Price: decimal
}

type ShippedOrder = {
    TrackingUrl: string
}

type CancelledOrder = {
    CancellationReason: string
}

type Order =
    | New
    | Validated of ValidatedOrder
    | Shipped of ShippedOrder
    | Cancelled of CancelledOrder
Enter fullscreen mode Exit fullscreen mode

DUs are akin to enhanced enums in F#, allowing encapsulation of distinct states and their associated data. They're typically used with pattern matching for clarity.

let matchOrder order =
    match order with
    | Shipped s -> $"Tracking URL: {s.TrackingUrl}"
    | Cancelled c -> $"Reason: {c.CancellationReason}"
Enter fullscreen mode Exit fullscreen mode

"Closed" Type Hierarchies

For those rooted in C#, the discriminated unions proposal has been around for quite some time. Apparently, it's not going to
make it in C#12 mainly because of several important discussion threads:

The "discriminated unions" and the "closed type hierarchies" are both viable language design alternatives and potent implementation options for the Order class. In this specific case, we even don't need the hierarchy to be "closed" Let's check the difference between the DU and the hierarchy approaches:

public abstract class OrderBase
{
    public IEnumerable<OrderItem> OrderItems { get; init; }    
}

public class ValidatedOrder : OrderBase
{
    public decimal? Price { get; init; }
}

public class ShippedOrder : OrderBase
{
    public string? TrackingUrl { get; init; }
}

public class CancelledOrder : OrderBase
{
    public string? CancellationReason { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Coincidentally, that's how EF core handles hierarchies by default.

Control Flow

The biggest difference between these two implementation options is how the control flow is organized. Discriminated unions use pattern matching while closed type hierarchies rely on good, old polymorphism.

Some people will claim the former option is more "functional" while the latter is more "object-oriented". Practically speaking, C# switch statements/expressions are, unlike F# match, not exhaustive. One of the main advantages of "closed" hierarchies is that they give us a list of all derived types at the compile time.

Concluding Thoughts

Whether using Discriminated Unions in F# or exploring Closed Type Hierarchies in C#, the goal remains: clear, maintainable, and robust code that accurately represents business logic and reduces room for errors. Leveraging the best of both functional and object-oriented paradigms can lead to superior code quality.

Top comments (0)