Jakob's Blog Personal insights into technologies

One enum to rule them all

Have you ever spent time writing boilerplate code around enums? With Rust, you don't have to!

In this article, I want to show how easy it has become in Rust to use a single enum type across many application domains. From SQL databases, through a web server and a GraphQL interface, all the way to a web client, we will use a single enum definition and zero boilerplate code.

Why should you care? I can think of many reasons but the most profound one is that enum modifications become super simple. Just add or remove a variant in the single definition and it’s done. No need to update serialization code and no chance for server and client source code to diverge.

Sounds good? Then let me give you the full code up-front.

#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] 
#[cfg_attr(feature = "strum", derive(EnumIter, Display))]
#[cfg_attr(feature = "graphql", derive(juniper::GraphQLEnum))]
#[cfg_attr(feature = "sql_db", derive(DbEnum), DieselType = "Duck_color")]
pub enum DuckColor {
    Yellow,
    White,
    Camo,
}

Wow, that surely is a lot of attributes! Now imagine having to write several lines of boring code for each of those attributes. Over and over again, for every enum you define. I for one, much prefer to have these attributes instead. But what are they? I will give a full explanation in the remainder of the article.

Some context around the case study

This enum DuckColor is a real example from one of my hobby projects, a browser game called Paddlers. The Paddlers (a.k.a. poorly drawn ducks) are the main characters of the game. This enum defines which sprite a particular duck/Paddler uses.

Three ducks with different colors.

pub struct Duck {
    pub id: i64,
    pub color: DuckColor,
    pub level: i32,
}

This is the last full struct you will see in this article. I will completely zoom in and only talk about enums from here onwards. Anything that is not directly related to them, I will glance over.

Should you find yourself wondering about some of the actual implementation details around the examples I present, fear not for I have you covered as well.

Previously, I wrote an article called Benefits of Full-Stack Rust where I explain in detail how I make the different components of the game work together nicely. It has more complete code examples and explains the concept of full-stack Rust much better than the article you are reading right now.

Sharing between multiple Rust applications

Before tackling the attributes, we need to quickly address how an enum can be shared across even just two applications. In our case, we have a web client and a web server. Both are written in Rust but compiled to WebAssembly and x86_64 respectively.

Those two crates have completely different dependencies and neither depends on the other. But they want to share an enum type, rather than duplicate the code. Luckily, the solution is simple. Just use a third crate that both crates can depend on. Here is a diagram of this highly complex situation.

A simple diagram with client and server pointing to the shared library.

Defining the enum DuckColor in this shared library makes it possible to share in all our other Rust crates. All good so far? Then let’s take on the long list of attributes.

Derive Macros on Enums

If you have some Rust experience, you have most likely seen these derive macros before.

#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]

Debug makes it work in println!("{:?}", DuckColor::Camo), PartialEq is for ==, Eq + Hash are needed to use the enum as a HashMap key, and finally, Clone + Copy lets me avoid borrowing all the time.

These are all built into the language. But what makes Rust immensely flexible is the option to create your own procedural macros. That is, a macro that can be written in Rust, takes Rust code as input, and generates some other Rust code.

Derive macros are just one shape of procedural macros and they are very useful on enums. The crate strum provides the perfect example for this. With #[derive(EnumIter)], it becomes possible to iterate over all variants of an enum.

for col in DuckColor::iter() {
    println!("{:?}", col);
}
/* Output is 3 lines: */
// Yellow
// White
// Camo

Of course, this can also be done in languages other than Rust. But usually, it involves a lot of boilerplate code. Having it solved once and for all in a macro, and then being able to share it through crates.io is just so much better.

Crate maintainers are typically pretty good at providing these macros where it makes sense. I actually have come to expect that they exist and feel disappointed when I don’t find them in the docs of a crate. Let’s have a look at some examples where I have not been disappointed.

An illustration showing a duck being sent through a network.

Transmitting an Enum through HTTP

To transfer an enum through a network, data has to be serialized on the sender side and deserialized by the receiver. Serde is by far the most popular library for this task in Rust.

A great strength of Serde is its elaborate derive macro support. It is highly configurable and works with pretty much all commonly used formats.

For enums, if I want each variant to serialize as its name in string format, I can just use #[derive(Serialize, Deserialize)] on it. In my case, I want it to be transformed to upper case with underscores. For that I also add #[serde(rename_all = "SCREAMING_SNAKE_CASE")].

REST API

The game Paddlers has a simple REST API that uses a JSON format on the network. Creating a JSON string with Serde looks like this:

let col: DuckColor = DuckColor::Yellow;
let json_string = serde_json::to_string(&col)?;
assert_eq!("\"YELLOW\"", json_string);

And a JSON string can equally easily be parsed back into a Rust type.

let parsed_col: DuckColor = serde_json::from_str(&json_string)?;
assert_eq!(parsed_col, DuckColor::Yellow);

GraphQL Server

The game also has a second API, which uses GraphQL. I’m using Juniper, which allows me to specify a GraphQL schema in pure Rust code. Juniper then manages the GraphQL server that responds to incoming queries.

GraphQL has native support for enums. That is to say, a GraphQL schema can define an enum type and a list of strings that are valid for that enum. To connect this to Rust, I allow Juniper to generate my schema based on the enum DuckColor, using the attribute #[derive(juniper::GraphQLEnum)]. This generates this GraphQL type:

enum DuckColor {
    YELLOW
    WHITE
    CAMO
}

So far so good, there is only one problem. I have to add the derive on the enum, which is defined in the shared library. That means the shared library has to depend on Juniper! Not ideal, since the client also depends on the library, which has no intention of using Juniper.

Of course, Rust has an elegant solution for this as well. The shared library can have an optional dependency on Juniper and define a feature that only the server uses.

# Cargo.toml of shared library
[dependencies]
juniper = { version = "0.15", optional = true }
[features]
graphql = ["juniper"]
# Cargo.toml of server
[dependencies]
shared-lib = { path = "../shared-lib", features = ["graphql"] }
juniper = "0.15"

Finally, the derive macro needs to be hidden behind conditional compilation flags like so:

#[cfg_attr(feature = "graphql", derive(juniper::GraphQLEnum))]
pub enum DuckColor {
    Yellow,
    White,
    Camo,
}

GraphQL Client

The last puzzle piece for consistent network transmission is the GraphQL client. I’m using the Rust crate called graphql-client for this. When you feed it with GraphQL queries, it spits out Rust code.

The generated code from graphql-client can be used to send queries to the server and parse the responses directly into proper Rust structs. These structs are generated from the query and validated against the GraphQL schema, which in turn can be extracted from Juniper.

Going back to enums, what graphql-client does by default with them is to generate a new enum type on the fly that matches the schema definition. That is generally a nice thing but not ideal in the situation at hand. Here, we would rather use the enum type defined in the shared library directly, or else we need an extra translation step.

Until a week ago, I had this extra translation step in Paddlers. But a small pull request later, it is now possible to specify an enum that should be used for deserialization instead of generating one anew.

The code for this is not on the enum type directly but rather on the generated query. It uses #[derive(GraphQLQuery)] to trigger the procedural macro and then the attribute macro #[graphql(...)] to configure the code generation.

#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "api/schema.json",
    query_path = "api/queries/ducks_query.graphql",
    extern_enums("DuckColor")
)]
pub struct DucksQuery;

Once again, the flexibility of procedural macros allows me to skip writing repetitive code for simple tasks.

An illustration showing a ducks stored in a database.

PostgreSQL Database

The last point to tackle is the database. I’m using PostgreSQL, which has great native enum support. Enum values are strings in SQL queries and in the returned data. Of course, all strings are validated against the enum definition. To keep things consistent, we just need a way to link between database enums and Rust enums.

Diesel is the ORM I use to link all SQL tables to Rust structs. It also validates the types of my queries. But the important bit for this article is the derive macro #[derive(DbEnum)]. With that, an enum column in the SQL table is automatically converted to its Rust equivalent when loading data.

By default, the type names must match exactly. But with the attribute #[DieselType = "Duck_color"], the link can be made to a differently named type.

Awesome, now all components respect one single enum definition! When I add or remove a variant in the definition, all parts of the system are updated automatically. Except….

Oh dear, what about the data in the database? What happens to existing ducks with the DuckColor::White when I replace that color with DuckColor::Grey? Should it update the rows? Or set the color to null? Or even delete the rows?

My point is, the database needs special attention in the case of an enum modification. A small migration SQL query will be necessary.

I don’t see any way Rust could help us out here. And I’m perfectly fine with that. This is not boilerplate code in my books, it is in fact code that requires thinking. Potentially even a meeting with the data owners to discuss the different consequences.

Maybe we could have generated suggestions for migrations scripts, based on what changed in Rust code. I think that could be helpful. But please, never automatically update my data just because I make small code changes!

So yeah, this is pretty much the limit of how tightly we can couple enumerations across application boundaries as of today. I’m pretty happy with that.

Final thoughts

I love enums. For me, they are the most iconic example of what programming is: Using a written language to express bits and addresses in a way that both computer and humans can understand. More concretely, we as humans can imagine the ducks in different colors and the computer can think 0, 1, 10.

And yet, I find support for enums in most languages very poor. Python added enums with version 3.4 and JavaScript still doesn’t have them.

Okay, maybe dynamically-typed languages fundamentally don’t try to help prevent programmers from making stupid mistakes like comparing Company::Apple with Fruit::Apple.

What about statically typed languages? Well, C has enums since 1972 and it existed in other languages before that. But a modern language like Go still gets away without them!

Then what about traditional OOP languages like C++, Java? They at least support the C-style enums. Additionally, enums are type-checked and not just another way to define constant integers, which is pretty much what C does.

Both C++ and Java also added some more bells and whistles over time. For example, modern C++ has scoped enums (defined using enum class) that behave a bit more sanely than normal enum.

Java even lets you define methods on enums, a feature which I haven’t seen much outside of Rust. And Java ships some useful built-in methods like DuckColor.values() which returns an array of all variants. Not too bad.

But even if you use the newest version of Java and especially C++, be prepared to bring your own boilerplate code for many use cases.

Rust embraces the idea of enums and multiplies it with the concept of tagged unions and pattern matching. Add the flexibility of procedural macros and combine it with a nice package manager, and you have one more reason why Rust is the future.

Let me know your thoughts, questions, and corrections on reddit.