Why I Stopped Worrying and Learned to Love the Rust Enum

Recently I have been vibe coding a project in Rust, and I was trying to embrace the “vibe” so to speak, but the agent was writing some code which I considered quite bad at first. I tried to prompt the agent to do a big refactor, but I ended up convincing myself that it is actually a much better way to structure the code.

Basically, the agent created a relatively large enum with around 5 different items, and instead of doing encapsulation as what I would expect to do back in school, it was basically the opposite of OOP. It was what I considered to be extremely bad coding practice at a first glance: the render functions know exactly what each thing is doing, there is no encapsulation, and the editing function also knows exactly how to edit everything.

When I was doing game dev, particularly with Unity, I was used to the idea that all the gameobject logic should belong to… well, the game object, and things like gravity etc. might be like components of that node or parents of that node. I thought that was a good way to program, because if I want to change the logic of say, the player game object, I will only need to touch the player class, instead of like this AI “slop” codebase where adding a feature or changing the shape of a single enum means touching a few dozen files.

However, as I was writing the request for the AI agents and trying to describe each logic, it suddenly clicked to me that the latter is actually NOT a bad way to write code, and arguably a much cleaner solution to my problem.

Let me explain.

To keep things general, I just want to say that the drawing logic that I have is quite complicated and relatively unique to each thing, but not entirely. But the most important part, and the part that is the most relevant, is that there are a lot of logics that are shared but are not directly inheritable.

So one example would be: we have a zombie shooter game. There are animals and humans. There are zombies and non-zombies. The chicken will share some logic with the zombie chicken, and the zombie human will share some logic with the zombie chicken. In the OO land, this will become the classic case of “why you should prefer composition over inheritance.” The OO answer to that is probably dependency injection.

But as I was reasoning through this, I realized there is actually no way to write less code if I am going with the DI route. With DI, or just a semi-OO setup like the Rust trait system, I need to:

  1. Write the trait for the object.
  2. Write the trait for each of the DI stuff.
  3. Migrate all the logic that is in a huge match statement in those functions line-by-line to different places—but the length is exactly the same, and probably longer because of the trait boilerplate.
  4. During serialization, instead of using the relatively intuitive and safe enum-based serialization, the user will most likely need to either create some magic string tag or… just introduce yet another enum, but this time it doesn’t hold data (aka, an enum in non-Rust land).

A huge match statement might look horrendous, and people on X (formerly known as Twitter) might see it as bad code, but it is actually significantly more maintainable because…

It is a Rust enum! Which means:

  1. I can add all the data I need.
  2. If there is a single missing arm, the program still won’t compile (so there is literally no case where those matches can miss anything).
  3. It is actually still a single source of truth: that Rust enum has all the data needed for that particular datatype to function EXACTLY as intended.

I remember Jonathan Blow once made a comment on the “optional datatype” or the tagged union in PL, and he said he never needed it, and why Jai didn’t have it. I was quite convinced by his argument back then. Because yes, at a glance, “sanitizing input” shouldn’t be that big of a deal, and yes, you technically can keep it relatively contained. But after this experience, I realized the real use of a tagged union is not about that, and it is precisely the opposite: it means you are ABLE to write programs in a meaningfully different way than if you are writing C or C++ or some other language that doesn’t have that. It means you actually get to think in something other than OOP, and I think that creates significantly more modular software.

OOP feels really good when you are designing the system: you create all those extremely dynamic solutions to a problem mostly using heavy dynamic dispatch after dynamic dispatch (like, that is exactly what a factory class that takes a dependency injected object and returns a child of an abstract class is doing), and it feels extremely complicated (because it really is). But now more than ever, I think that creates worse software, because it groups “objects” together but it fragments… well, the actual logic. The rendering is now scattered all over the codebase, and the redo/undo logic is not in a centralized place but handled by each object, all in the name of “hiding the implementation detail.”

But data types matter, and implementation details matter. When you realize the fact that there is actually no reasonable way to design an object that handles both its rendering and its physics, and you have to then use something like a “rendering node” and then a “physics” node, where each node also has to be exposed to the same data so that they can actually do their job, you should realize that you are implementing something in the wrong way.

Yes, this is just yet another OOP rant.