C# Enums with Payloads: Because Sometimes a Simple Value Just Isn’t Enough

Introduction

As some of you may know, I love working with C#, as it seems to get better with every iteration, but I also love Rust, because of its safety and its performance. Rust has something that C# sorely misses, namely enums with payloads, or sumtypes. Let me give you an example in Rust:

enum CarCommand {
    Turn(Direction),
    Stop,
    Start,
    GetState(mpsc::Sender<CarCommand>),
}

What does this do?

  1. It creates a new enum, with four variants.
  2. Two of those variants carry no data, and are therefore more or less equivalent to what we already have in C#.
  3. However, the Turn(Direction) variant takes a bit of data, namely a Direction enum.
  4. And finally the GetState(mpsc::Sender<CarCommand>) takes a complex type, discussion of which is outside the scope of this article.

Why do we need this? Because with this you can do things like this:

        match self {
            CarCommand::Turn(direction) => write!(f, "Turn: {:?}", direction),
            CarCommand::Stop => write!(f, "Stop"),
            CarCommand::Start => write!(f, "Start"),
            CarCommand::GetState(_) => write!(f, "GetState"),
        }

The reference to self is more or less equivalent to this in C#. You see that we not only can match the enum values, but also provide some useful data to it. Also note that the _ means we do not care about the supplied data, and also that we do not need to check the default case as the Rust match statement is exhaustive.

How to implement this in C#?

We will build something similar in C# using abstract classes, subclasses and pattern matching. In this example we will do something similar as in the Rust example, namely control a vehicle.

We will start very simple by defining a Direction enum:

    enum Direction
    {
        Left,
        Right
    }

Now we will define a VehicleCommand class, which will be internal, as we will only be using it in this app, and abstract for reasons which will be come apparent:

    public abstract record VehicleCommand
    {
    }

Why is the record abstract? That is to prevent instantiating it, as it is meant to be a base type for specific commands. Also it clearly communicated intent signalling that VehicleCommand is a basetype that should only be used through its nested derived types, ensuring all instances are one of the specific command types.

In this record we will start by defining a Start record which is empty:

public record Start : VehicleCommand;

The Stop record is equally empty:

public record Stop : VehicleCommand;

We will later see how these records can be used. Now we come to the Forward record which signals to the vehicle to move forward for a certain distance:

public record Forward(int Distance) : VehicleCommand;

You see that we add a little bit of extra data to it, in this case the distance. The Turn record looks similar, but instead of a distance, we provide a direction:

public record Turn(Direction Direction) : VehicleCommand;

The whole VehicleCommand record looks like this:

    public abstract record VehicleCommand
    {
        public record Start : VehicleCommand;
        public record Stop : VehicleCommand;
        public record Forward(int Distance) : VehicleCommand;
        public record Turn(Direction Direction) : VehicleCommand;
    }

Why use records instead of classes?

Records have some advantages over classes in this case:

  • One-Liners: They are one liners. You no longer need to manually define properties or constructors.
  • Immutability: Just like Rust variants, record properties by default are init-only. This prevents the “payload” from being accidentally changed after the command is created.

How to use these records?

Let’s see how to use these records, in your main() method start by adding this statement:

            List<VehicleCommand> commands =
            [
                new VehicleCommand.Start(),
                new VehicleCommand.Forward(10),
                new VehicleCommand.Turn(Direction.Left),
                new VehicleCommand.Forward(5),
                new VehicleCommand.Turn(Direction.Right),
                new VehicleCommand.Stop()

            ];

What is happening here? We create a new list of VehicleCommand called commands and fill it with instances of its subrecords. We instantiate VehicleCommand.Start() command first, and so on. Since all these are subrecords of VehicleCommand they can be elements in the list.

Next we will iterate over these elements:

            foreach (var command in commands)
            {
                var message = command switch
                {
                    VehicleCommand.Start => "Vehicle started",
                    VehicleCommand.Stop => "Vehicle stopping",
                    VehicleCommand.Forward(var forward) => $"Vehicle moving forward {forward} units",
                    VehicleCommand.Turn(var turn) => $"Vehicle turning {turn}",
                    _ => "Unknown command",
                };
                
                Console.WriteLine(message);
            }

During each iteration we try and evaluate a switch-expression: in this expression, we pattern match on the command and assign the result to the message variable. Note that we do not need break statements, because when a match is found it is assigned to the variable and the switch expression automatically exits.

Finally we print out the message.

Conclusion

This approach demonstrates how far C# has come in narrowing the gap between object-oriented and functional programming paradigms. While C# doesn’t yet have native “discriminated unions” in the same way Rust does, simulating them with abstract records and pattern matching is a powerful, type-safe alternative.

Summary of the Simulation

By using this pattern, you gain several benefits that traditional C# enums simply can’t provide:

  • Type Safety: The compiler ensures that you can only access the “payload” (like Distance or Direction) when you have successfully matched the correct variant.
  • Immutability: By using records, you ensure that once a command is issued, its data cannot be tampered with—mirroring Rust’s safety guarantees.
  • Readability: The switch expression acts as a clean, declarative way to handle different logic branches without the boilerplate of old-school if-else or is checks.

The Code Nomad
The Code Nomad
Articles: 165

Leave a Reply

Your email address will not be published. Required fields are marked *