Node to Rust, Day 9: Enums and Methods

Node to Rust, Day 9: Enums and Methods

December 9, 2021

Introduction

Yesterday we went over how to create your first struct, the Rust data structure that is most like a JavaScript class. Today we’ll translate method syntax, TypeScript enums, and show how to use Rust’s match expressions.

We’ve started using more TypeScript than JavaScript. To run examples, install ts-node with npm install -g ts-node. If you want a TypeScript playground, check out this typescript-boilerplate.

The code in this series can be found at wasmflow/node-to-rust

This guide is not a comprehensive Rust tutorial. This guide tries to balance technical accuracy with readability and errs on the side of “gets the point across” vs being 100% correct. If you have a correction or think something needs deeper clarification, send a note on Twitter at @jsoverson, @candle_corp, or join our Discord channel.

Adding methods to our struct

Depending on who you talk to and the time of day you’ll get different definitions for words like “function,” “method,” “procedure,” et al. When I use “method” and “function” here, I’m differentiating between a callable subroutine meant to be executed within the context of instantiated data (a method) and one that isn’t (a function).

The new function we added yesterday is like a static function in a TypeScript class. You can call it by name without instantiating a TrafficLight first.

impl TrafficLight {
  pub fn new() -> Self {
    Self {
      color: "red".to_owned(),
    }
  }
}
// let light = TrafficLight::new()

Adding methods to Rust structs is straightforward. Mostly.

Consider how you’d add a method like getState() in TypeScript:

class TrafficLight {
  color: string;

  constructor() {
    this.color = "red";
  }

  getState(): string {
    return this.color;
  }
}

const light = new TrafficLight();
console.log(light.getState());

By default, every method you add to a TypeScript class is public and is added to the prototype to make it available to all instances.

In Rust, every function in an impl defaults to private and is just an everyday function. To make something a method, you specify the first argument to be self. You won’t frequently need to specify the type of self. If you write &self, the type defaults to a borrowed Self (i.e. &Self). When you write self, the type is Self. You will see libraries that specify more exotic types for self, but that’s for another time.

You may find yourself in a state where you have an instance of something and you know there’s a method, but you just can’t call it. It could be two things. 1) You’re trying to call a trait method that hasn’t been imported. You need to import the trait (i.e. use [...]::[...]::Trait;) for it to be callable. 2) Your instance needs to be wrapped with a specific type. If you see a function like fn work(self: Arc<Self>), then you can only call .work() on an instance that is wrapped with an Arc.

To implement our getState() method in Rust, we write:

pub fn get_state(&self) -> &String {
  &self.color
}

This method borrows self, has a return type of &String, and returns a reference to its internal property color. The full code looks like this:

fn main() {
  let mut light = TrafficLight::new();
  println!("{:?}", light);
}

impl std::fmt::Display for TrafficLight {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    write!(f, "Traffic light is {}", self.color)
  }
}

#[derive(Debug)]
struct TrafficLight {
  color: String,
}

impl TrafficLight {
  pub fn new() -> Self {
    Self {
      color: "red".to_owned(),
    }
  }

  pub fn get_state(&self) -> &str {
    &self.color
  }
}

&self vs self

I made the point of calling out that the method above borrows self. The natural corollary is that there must exist methods that don’t borrow self, methods that take ownership of self. If a method requires an owned self then that must mean the calling code gives up ownership when it calls the method.

To re-iterate: the calling code loses the instance when it calls the method.

Don’t believe me? Let’s see what happens when we change the method to this:

pub fn get_state(self) -> String {
  self.color
}

And call it twice like:

fn main() {
  let light = TrafficLight::new();
  light.get_state();
  light.get_state();
}

This won’t compile. Rust will give you an error message you’ll get very used to: “use of moved value.”

error[E0382]: use of moved value: `light`
  --> crates/day-9/structs/src/main.rs:4:18
   |
2  |   let light = TrafficLight::new();
   |       ----- move occurs because `light` has type `TrafficLight`, which does not implement the `Copy` trait
3  |   println!("{}", light.get_state());
   |                        ----------- `light` moved due to this method call
4  |   println!("{}", light.get_state());
   |                  ^^^^^ value used here after move
   |
note: this function takes ownership of the receiver `self`, which moves `light`
  --> crates/day-9/structs/src/main.rs:25:20
   |
25 |   pub fn get_state(self) -> String {
   |                    ^^^^

For more information about this error, try `rustc --explain E0382`.

Losing a value (having it moved) when you call a method might be hard to wrap your head around at first. You never deal with such a concept in JavaScript. However, you do write code where it would make sense. Scenarios like:

  • In conversions: When you take some data and convert it to another. In Rust you literally take (take ownership of) some data and return (give away ownership to) new data.
  • In cleanup code. When an object gets destroyed or otherwise cleaned up, the calling code is usually done with the instance.
  • In builder patterns or chainable APIs. You can take an owned self, mutate it, and return it so the calling code can chain on another method.

There are other use cases and even more that require different ways of thinking about self. You’ll get there in time.

Mutating state

Things are going swimmingly but our TrafficLight isn’t very useful. It never changes color. Everything in Rust is immutable by default. Even our own self. We need to mark this method as one that can needs a mutable self. If we wrote our method like this…

pub fn turn_green(&self) {
  self.color = "green".to_owned()
}

…Rust would yell at us

error[E0594]: cannot assign to `self.color`, which is behind a `&` reference
  --> crates/day-8/structs/src/main.rs:32:5
   |
31 |   pub fn turn_green(&self) {
   |                     ----- help: consider changing this to be a mutable reference: `&mut self`
32 |     self.color = "green".to_owned()
   |     ^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be written

For more information about this error, try `rustc --explain E0594`.

What we need is a mutable reference (see Day 5: Borrowing & Ownership).

We need &mut self.

pub fn turn_green(&mut self) {
  self.color = "green".to_owned()
}

We also need to mark our instance of TrafficLight as mutable in the calling code. Otherwise Rust will yell at us again.

In main(), change let light = ... to let mut light = ....

let mut light = TrafficLight::new();

Our code now looks like this

fn main() {
  let mut light = TrafficLight::new();
  println!("{:?}", light);
  light.turn_green();
  println!("{:?}", light);
}

impl std::fmt::Display for TrafficLight {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    write!(f, "Traffic light is {}", self.color)
  }
}

#[derive(Debug)]
struct TrafficLight {
  color: String,
}

impl TrafficLight {
  pub fn new() -> Self {
    Self {
      color: "red".to_owned(),
    }
  }

  pub fn get_state(&self) -> &str {
    &self.color
  }

  pub fn turn_green(&mut self) {
    self.color = "green".to_owned()
  }
}

And it’s output is

[snipped]
TrafficLight { color: "red" }
TrafficLight { color: "green" }

Enums

If you’re like me, you were getting itchy seeing “red” and “green” written out as strings. Using data types like strings or numbers to represent a finite set of possibilities (i.e. red, green, and yellow) leaves too much opportunity for failure. This is what enums are for.

To migrate our string to an enum in TypeScript, you’d write this:

class TrafficLight {
  color: TrafficLightColor;

  constructor() {
    this.color = TrafficLightColor.Red;
  }

  getState(): TrafficLightColor {
    return this.color;
  }

  turnGreen() {
    this.color = TrafficLightColor.Green;
  }
}

enum TrafficLightColor {
  Red,
  Yellow,
  Green,
}

const light = new TrafficLight();
console.log(light.getState());
light.turnGreen();
console.log(light.getState());

This prints

0
2

TypeScript’s default enum value representation is a number but you can change it to a string via:

enum TrafficLightColor {
  Red = "red",
  Yellow = "yellow",
  Green = "green",
}

In Rust, enums are similarly straightforward:

enum TrafficLightColor {
  Red,
  Yellow,
  Green,
}

With our struct and implementation changing as such:

struct TrafficLight {
  color: TrafficLightColor,
}

impl TrafficLight {
  pub fn new() -> Self {
    Self {
      color: TrafficLightColor::Red,
    }
  }

  pub fn get_state(&self) -> &TrafficLightColor {
    &self.color
  }

  pub fn turn_green(&mut self) {
    self.color = TrafficLightColor::Green
  }
}

enum TrafficLightColor {
  Red,
  Yellow,
  Green,
}

Now though, we’re bitten by the the traits we implemented and derived. VS Code and rust-analyzer are probably already yelling at you because we just made our TrafficLight unprintable and undebuggable because TrafficLightColor is both unprintable and undebuggable.

We need to derive Debug and implement Display for TrafficLightColor just as we did with TrafficLight. We can derive on an enum exactly the same way we did with our struct.

Add #[derive(Debug)] just before the enum definition.

#[derive(Debug)]
enum TrafficLightColor {
  Red,
  Yellow,
  Green,
}

That took care of one problem. Now we have to implement Display. That’ll be a little different this time. We want to write out a different string for every variant. To do that we use a match expression. match is similar to a switch/case except better in every way.

First things first, let’s making writing this stuff easier. Write out the impl for Display like this:

impl Display for TrafficLightColor {}

If your code follows along with ours, VS Code will complain at Display, saying "cannot find trait `Display` in this scope". Place your cursor on display and press Ctrl+. (or hover and press “Quick fix”). If rust-analyzer has any suggestions, you’ll see them in a drop down menu.

Drop down showing &ldquo;Import Display&rdquo;

Select Import Display and select std::fmt::Display from the next drop down. VS Code will take care of adding the use std::fmt::Display; line at the top of your file. Nice! Free code!

But now we have an even longer squiggly red line.

Line showing error for Display impl

Do the Ctrl+. shuffle once again, select Implement missing members and voila! You’ve got the boilerplate out of the way.

impl Display for TrafficLightColor {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    todo!()
  }
}

Get used to using this, it’s a life saver.

the todo! macro panics. It’s a useful, temporary placeholder.

A match expression allows us to match the result of an expression against a pattern. The following code matches the possible values of TrafficLightColor against its self to produce an appropriate display string.

impl Display for TrafficLightColor {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    let color_string = match self {
      TrafficLightColor::Green => "green",
      TrafficLightColor::Red => "red",
      TrafficLightColor::Yellow => "yellow",
    };
    write!(f, "{}", color_string)
  }
}

write! is another macro. It takes a formatter + formatting arguments and returns a Result. A Result is like an Option and we’ll get to it soon. Just think of write! as the print! you use when implementing Display.

Our final code looks like this:

use std::fmt::Display;

fn main() {
  let mut light = TrafficLight::new();
  println!("{}", light);
  println!("{:?}", light);
  light.turn_green();
  println!("{:?}", light);
}

impl std::fmt::Display for TrafficLight {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    write!(f, "Traffic light is {}", self.color)
  }
}

#[derive(Debug)]
struct TrafficLight {
  color: TrafficLightColor,
}

impl TrafficLight {
  pub fn new() -> Self {
    Self {
      color: TrafficLightColor::Red,
    }
  }

  pub fn get_state(&self) -> &TrafficLightColor {
    &self.color
  }

  pub fn turn_green(&mut self) {
    self.color = TrafficLightColor::Green
  }
}

#[derive(Debug)]
enum TrafficLightColor {
  Red,
  Yellow,
  Green,
}

impl Display for TrafficLightColor {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    let color_string = match self {
      TrafficLightColor::Green => "green",
      TrafficLightColor::Red => "red",
      TrafficLightColor::Yellow => "yellow",
    };
    write!(f, "{}", color_string)
  }
}

And outputs

[snipped]
Traffic light is red
TrafficLight { color: Red }
TrafficLight { color: Green }

Wrap-up

Structs and enums are the most important structures you will deal with in Rust. Rust enums encapsulate common usage like above but are also Rust’s answer to union types. They can represent much more complex values than TypeScript. Similarly, match expressions are also much more powerful than we let on above. You’ll frequently use enums and match expressions hand in hand. Don’t ignore them or push off learning more about them. You’ll regret it because it changes the way you think about code in Rust.

It’s important to take our time going through these sections. It’s easier to highlight the nuance and the error messages when there’s a natural flow of code progression. Some sections take this route, others are more direct mapping of TypeScript/JavaScript to Rust. If you have comments on what you like better about one style or the other, drop me a line!

As always, you can reach me personally on twitter at @jsoverson, the Candle team at @candle_corp, and our Discord channel.

Written By
Jarrod Overson
Jarrod Overson