
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
withnpm install -g ts-node
. If you want a TypeScript playground, check out this typescript-boilerplate.
Quick links
- Day 1: From nvm to rustup
- Day 2: From npm to cargo
- Day 3: Setting up VS Code
- Day 4: Hello World (and your first two WTFs)
- Day 5: Borrowing & Ownership
- Day 6: Strings, part 1
- Day 7: Syntax and Language, part 1
- Day 8: Language Part 2: From objects and classes to HashMaps and structs
- → Day 9: Language Part 3: Class Methods for Rust Structs (+ enums!)
- Day 10: From Mixins to Traits
- Day 11: The Module System
- Day 12: Strings, Part 2
- Day 13: Results & Options
- Day 14: Managing Errors
- Day 15: Closures
- Day 16: Lifetimes, references, and
'static
- Day 17: Arrays, Loops, and Iterators
- Day 18: Async
- Day 19: Starting a large project
- Day 20: CLI Arguments & Logging
- Day 21: Building and Running WebAssembly
- Day 22: Using JSON
- Day 23: Cheating The Borrow Checker
- Day 24: Crates & Tools
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 likefn work(self: Arc<Self>)
, then you can only call.work()
on an instance that is wrapped with anArc
.
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.
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.
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 anOption
and we’ll get to it soon. Just think ofwrite!
as theprint!
you use when implementingDisplay
.
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
