03 Feb 24

Rust Enums compared to Ruby objects

So far we’ve discussed Strings and Numbers which are relatively comparable in Rust and Ruby. At least both languages have those concepts as first-class citizens. In this post, we are going to discuss Enums.

If you are coming from Ruby, you may not be completely familiar. More than likely you’ve heard of Enums in the context of database validation. Typically, we’d use Enums while deciding if a given value is an acceptable value for a database column. In Ruby, a lot of times this is done using a list of acceptable values.

In our project files, we’ll create types of users. The types will be used to illustrate the use of Enums.

In Ruby, we could do something like this:

class Admin
   NAME = "Administrator"
end

class NonAdmin
   NAME = "Application User"
end

class User
   USER_KINDS = [Admin, NonAdmin]
   attr_accessor :kind

   def self.admin
      USER_KINDS.first
   end

   def self.nonadmin
      USER_KINDS.last
   end
end

my_user = User.new
my_user.kind = User.admin # we could have used USER_KINDS.first

puts my_user.kind # prints out Admin
puts my_user.kind::NAME # prints out Administrator

We could add validation to this to make sure that User#kind is only ever set to a value included in USER_KINDS, but let’s see what the equivalent is in Rust.

pub struct User {
   kind: UserKind,
}

#[derive(Debug)]
pub enum UserKind {
   Admin,
   NonAdmin,
}

#[derive(Debug)]
pub struct Admin {}

impl Admin {
   const NAME: &str = "Administrator";
}

#[derive(Debug)]
pub struct NonAdmin {}

impl NonAdmin {
   const NAME: &str = "Application User";
}

let my_user = User {
   kind: UserKind::Admin,
};
println!("{:?}", my_user.kind); // prints Admin

let kind = my_user.kind;
if let UserKind::Admin = kind {
   println!("name: {}", Admin::NAME);
}

You can see we declare an object with type enum on line 6. Enum is a first-class citizen in Rust. It is made up of variants. Our variants are Admin and NonAdmin. Each is a struct with a constant implemented defining a unique name.

Notice when we define our my_user variable we set a value with a kind. That kind points to the specific variant of the enum. In the example above, that is UserKind::Admin. One of the first things users will do is try to access the specific variant through my_user. Notice where we print out the value of my_user.kind. It says Admin. But if we try my_user.kind::NAME to try and access the declared constant in the Admin struct, we’ll receive a compilation error. Our value is a pointer to that variant struct, but not an instance of the struct itself. The last 4 lines show a way of accessing the NAME constant value of the Admin class, in relation to our my_user variable. When you are used to the expressiveness in Ruby, this illustration may feel like a slight backpedal just to access a property of an object.

But, we’re not illustrating the utility of Enums without trying to set the value to something invalid. Let’s set the my_user kind to just UserKind. The compiler will tell us that it is looking for a value other than an Enum. Ok, that is good check. Let’s try a variant that doesn’t exist. Let’s set my_user kind to UserKind::Invalid. The compiler tells us there is no variant by that name for the Enum. Let’s define a new Enum and try a variant from it.

pub enum New {
   Admin,
}

let my_user = User { kind: New::Admin };

The compiler will yell at us about mismatched types. This gives us a good idea about Enums and how they are first-class citizens.

There are a few different kinds of variants that can be declared. Above we’ve only used basic variants. Let’s take a look at a tuple variant. In simple terms, a tuple variant is a variant that takes a value.

Let’s add a score property to our User struct and its values will come from a new Enum. The variants will take a value representing either a high or low score for the user.

pub struct User {
   kind: UserKind,
   score: UserScore,
}

#[derive(Debug)]
pub enum UserScore {
   HighScore(i8),
   LowScore(i8),
}

let my_user = User {
   kind: UserKind::Admin,
   score: UserScore::HighScore(99),
};

let score = my_user.score;
if let UserScore::HighScore(value) = score {
   println!("value: {}", value);
}

The first thing to notice is that our new Enum has two variants. A high score and a low score and they both expect an i8 value. We know from the previous post on Numbers, that i8 is an 8-bit signed integer. When defining our my_user variable now, we also assign a score type of UserScore::HighScore(99). This uses the HighScore variant of our UserScore Enum and assigns it a value of 99. Then our last three lines illustrate how to later pull that data back out by checking the score type against the HighScore variant with a value placeholder. The placeholder then holds the value we used in our assignment - 99.

Now let’s talk about struct-like variants. When using tuple variants, things can get a little unorganized with multiple values being passed in. To better support that, Rust supports struct-like variants so we can name the various values being passed in like we do properties of a struct.

Let’s introduce a new Enum called UserContact that will hold either electronic or physical contact information. We’ll add a contact field to our User struct with a type of UserContact. Then assign our my_user an electronic type of the variant with two values.

pub struct User {
   kind: UserKind,
   score: UserScore,
   contact: UserContact,
}

#[derive(Debug)]
pub enum UserContact {
   Electronic { email: String, text: String },
   Physical { city: String, state: String },
}

let my_user = User {
   kind: UserKind::Admin,
   score: UserScore::HighScore(99),
   contact: UserContact::Electronic {
      email: String::from("user@example.com"),
      text: String::from("1235131234"),
   },
};

let contact = my_user.contact;
if let UserContact::Electronic { email, text } = contact {
   println!("email: {}, text: {}", email, text);
}

Notice that the UserContact variables name the arguments (like we can do in Ruby method definitions). And in our if let statement on the third to last line, we compare our my_user contact value to a struct-like variant that includes the named fields of email and text. We can then use those values in our second to last line printing out the values.

To further illustrate, let’s accomplish the same thing with Ruby. We’ll make inline comments about any differences that are hard to reach parity on.

class User
    USER_KINDS = [Admin, NonAdmin]
    USER_SCORES = [HighScore, LowScore]
    USER_CONTACTS = [Electronic, Physical]

    attr_accessor :kind, :high_score, :low_score, :contact

    # note: we are using class methods to obtain the allowed kinds
    # one nicety about Rust enums is the compiler applying validation
    # based on types
    def self.admin
      USER_KINDS.first
    end

    def self.nonadmin
      USER_KINDS.last
    end

    # note: same as user kind, we are using class methods to return the allowed types
    def self.high_score(value)
      USER_SCORES.first.new(value)
    end

    def self.low_score(value)
      USER_SCORES.last.new(value)
    end

    # note: we aren't reaching parity here compared to the Rust version
    # in the Rust version, the user's contact is an enum and the variant determines the type
    # in the Ruby version, we store either one as the contact
    # we could reach parity by introducing a few more objects like a UserContact class 
    # that would return one or the other like the two enum variants in Rust
    def self.contact(email:, text:, city:, state:)
      if !email.nil? || !text.nil?
        USER_CONTACTS.first.new(email: email, text: text)
      elsif !city.nil? || !state.nil? 
        USER_CONTACTS.last.new(city: city, state: state)
      end
    end
  end

  my_user = User.new
  my_user.kind = User.admin
  my_user.low_score = User.low_score(30)
  my_user.contact = User.contact(email: "user@example.com", text: "1235131234", city: nil, state: nil)

  puts my_user.kind
  puts my_user.kind::NAME
  puts my_user.low_score.score
  puts my_user.contact.email

You can see how we can achieve very similar things in both Ruby and Rust in the context of Enums, but the execution is quite a bit different. In the end, Ruby doesn’t have a first-class Enum citizen, so we have to create some of the functionality ourselves depending on the needs of our applications.

One thing we didn’t cover here is defining methods on an Enum. The good news is that you can do that Rust. But we’re going to leave implementing methods on objects for the next post where we dive into what Rust calls classes–Structs. We probably should have discussed Structs before Enums, but Enums illustrate a couple of differences between the languages.

Music companion of this post:

Ist Ist — Protagonists

What I am working on currently:

I am multitasking at the moment: expanding the API for my startup–Schemabook and CQRS style events going into Kafka inside a Rust app (converting from Rabbit to Kafka)