<![CDATA[Carrot Games]]> http://community.carrot-games.com Thu, 16 Apr 2026 17:42:50 +0000 Smartfeed extension for phpBB http://community.carrot-games.com/styles/carrotgames/theme/images/site_logo.svg <![CDATA[Carrot Games]]> http://community.carrot-games.com en-gb Thu, 16 Apr 2026 17:42:50 +0000 60 <![CDATA[Langdev :: The power of discriminated unions is exhaustivity, not the unions :: Author celes]]> http://community.carrot-games.com/viewtopic.php?f=21&t=36&p=95#p95 preview version for union types!! There's a catch though.

To clarify, union here means discriminated union, not a C-style union. It's what Rust calls an enum when the variants of the enum have payloads and not just a tag. I think zig calls these tagged unions, and if you're enjoying a nice cup of coffee at the top of the ivory tower you're probably making fun of all the silly ways the commoners refer to what you know as sum types.

Anyway, discriminated unions are cool, because they let you model a closed list of different things. Emphasis on the closed aspect. It's the fact that they're closed which turns these unions into a superpower. Consider the alternative: You want to model a bunch of cases, say you have input bindings from your game, which could be one of[1]:
  • A mouse button
  • A mouse axis (movement)
  • A gamepad axis
  • A gamepad button
  • A keyboard key
A regular C# enum is not enough here. Each of these cases has a different payload: The gamepad button will have a GamepadButton payload whereas the keyboard key needs a KeyboardKey payload.

There's two ways you could model this in C# before:

The bad one:

Code: Select all

enum InputEventKind {
    MouseButtonCase,
    MouseAxisCase,
    GamepadAxisCase,
    GamepadButtonCase,
    KeyboardKeyCase
}

struct InputEvent {
    InputEventKind kind;
    MouseButton? mouseButton;
    MouseAxis? mouseAxis;
    GamepadAxis? gamepadAxis;
    GamepadButton? gamepadButton;
    KeyboardKey? keyboardKey;
}
Or the even worse one:

Code: Select all

interface InputEvent;

struct MouseButtonCase : InputEvent {
    MouseButton mouseButton;
}

struct MouseAxisCase : InputEvent {
    MouseAxis mouseAxis;
}

// .. and so on
The two cases are somewhat equivalent in shittiness, even though proponents of the OOP paradigm would clearly argue for the second. The fundamental advantage discriminated unions have over those is that they're a TODO list generator. They are optimized for the use case of: "Tell me all the places I need to touch after adding a new variant or removing an existing one". This is something version 1 up here doesn't do at all, and version 2 can do poorly if you add methods to the InputEvent interface, which breaks code locality by scattering each branch of each if statement into a separate file (or if you're a sane person, different locations in the same file. I never bought the "one class per file" bs).

Anyway, I think that's more than enough context. The key concept here is exhaustivity. Unions allow us to do this:

Code: Select all

union InputEvent(GamepadButton, GamepadAxis, MouseButton, ...and the others);
And the nice thing about this is not the succint syntax or the fact this generates a more memory efficient (but more or less semantically equivalent) to shitty version 1. The nice thing about unions lies not in their declaration, but their usage:

Code: Select all

void Foo(InputEvent ev) =>
    ev switch {
        GamepadButton gb => $"Gamepad button {gb}",
        GamepadAxis ga => $"Gamepad axis {ga}"
        MouseButton mb => ..., // and so on...
        MouseAxis ma => ...,
        KeyboardKey kb => ...,
    };
Look at this! We're basically writing Haskell now!! The nice thing about the union is the fact that switch cannot handle just "some" of the branches. It is required by the compiler to exhaustively handle all 6, or explicitly opt out by adding a wildcard branch (e.g. with _): Otherwise, you get an error.

Those errors are the useful feature, the TODO list generator. When you add a new case, say, idk VrControllerGesture? The compiler will remind you of all the new locations where you need to handle this new variant. All the glue code and plumbing that's just passing InputEvents without taking a look inside remains untouched!

But here comes the catch. C# screwed up big time in their implementation, because they limited exhaustiveness to the switch expression construct. This would be a non-issue in an expression-based language. Like any Lisp, Haskell, or even Rust. In those languages, statements are either not a thing at all or we have some kind of hybrid where all statements (including blocks containing multiple statements) are, themselves, valid expressions. C# however cannot do that. What this means is pattern matching on enum variants with exhaustiveness checking is limited to.... a single expression :akko_nope:

Yes you heard it right. Say you want to have *two lines of code* to handle one of the cases? Can't do that. Do you want to return from some branches but not others? No can do either. Break or continue? Same shit. All these things require statements. Forget about having if statements inside one of these branches, let alone loops.

Some people recommend the goofy pattern of declareing a lambda that's immediately called, the old JS trick (they called it IIFE, for immediately-invoked function expression). Lambdas are an expression, so this works. However, at this point your solution is just as bad as the existing source-generator-based implementations for discriminated unions which already rely on the same trick. You can't return / break / continue from inside a lambda, you can't access struct ref variables either, and setting locals inside the lambda will cause them to be moved to the heap.

Note that you can do pattern matching for union variants using a chain of if statements or the legally distinct switch statement (a completely different thing from the switch expression!). The problem with those two is that the compiler only enforces exhaustiveness for switch expressions and nothing else, and discriminated unions are pretty useless without the exhaustiveness.

I understand why they did what they did. The switch expression is the only language construct that checked exhaustiveness for other datatypes. Still, I would've welcomed some sort of ugly [EnforceExhaustive] annotation to put on top of my switch statements so that the feature would stay useful in non-toy examples.

As things stand right now, they did all this work and to me it's as if they'd done nothing. In fact it's less than nothing, because all those oop zealots who don't know better might be satisfied by this feature and it will be left there as yet another little situational language quirk. If we reach that point, the feature is as good as dead. Let's hope it doesn't get to that.

I will keep my current state of not holding my breath for any new upcoming C# (or any language, for that matter) features and appreciate technology for what it is today, and not what it could be tomorrow. I don't think I'll be adopting this new feature unless there's a significant change of direction. Especially when there's already good alternatives like DUSharp that offer no significant downsides compared to the new "official" version.

[1]: I think the people in the original blog are making a huge disservice to the feature by using an example of a Pet union with Cat and Dog variants. It's not a good example for a closed thing since modeling animal taxonomies is one of the canonical examples for class inheritance. People should stop treating their readers as stupid.]]>
no_email@example.com (celes) http://community.carrot-games.com/viewtopic.php?f=21&t=36&p=95#p95 Tue, 07 Apr 2026 07:55:49 +0000 http://community.carrot-games.com/viewtopic.php?f=21&t=36&p=95#p95