Get serious about rust, by writing more rust

Posted by Orville Bennett on 31 December 2023
Read time: about 10 minutes

Prologue

A few years ago I was working on a design system with a team that was in the midst of implementing a new color system. The system was mostly complete and what was left was documentation and tooling to ease working with, generating or modifying color tokens. We'd chosen to write these tools in JavaScript (JS).

Around that time a company hackathon—my very first—was being organized. As I was familiar with TypeScript by this time, I thought typing our JS command line tool would be a good, safe contribution. But in the back of my mind I had a nagging thought: “It’s not enough.”

Of course I could add types to an existing tool, make it more robust, and make development easier for those unfamiliar with the codebase1. For this, my first ever hackathon I thought, “Go Big!” So right before the hackathon started I pivoted to “Reimplement color tool in rust." As a stretch goal, I would try to have a WASM output so we could embed it into a webpage, allowing easier use by designers.

I failed, of course. Didn’t know enough rust as it turned out. And, unlike all other languages I had ever learned—Ruby, PHP, Python, Swift, Java, JavaScript—it was not similar enough for me to just pick it up “on the fly”. All I was able to manage with my compatriots was to get the command line parsing done. But when it was done, ohohooo! How well that command line parsing worked! It was glorious. The strictness of the compiler gave me helpful information in the errors. I thought to myself, “If an entire program were written like this it would be near perfect”.

Thus began my obsession interest in rust. With an altogether too ambitious hackathon project that failed. I really shouldn’t call it a failure though, because we only truly fail, if we learn nothing from our failures, and oh the things I’d learned. Such as:

  • my mental model for dealing with data did not mesh with the rust compiler’s.
  • a compiler could be helpful, suggesting solutions to problems, not just providing cryptic hints.
  • language tools could be a joy to use.
  • strictness (which I had eschewed since embracing JS) was not necessarily opposed to ease of use.

Fun fact: writing rust, teaches you more rust

When writing rust you will be told when you have broken the rules: this phenomenon is known as the compilation error. Typical compilation errors can be frustratingly obtuse. The rust compiler's errors are just the opposite. They point to exactly where a rule was broken, and give suggestions on how to comply; it's actually trying to help you! As your reward for following its rules, you are rewarded with swiftness in carrying out requests, and a freedom from crashes caused by accessing memory that you no longer own. An example:

 cargo build
error[E0382]: borrow of moved value: `filename`
   --> src/generators.rs:190:13
    |
185 |         let mut filename = get_file_location(config.output_dir)?;
    |             ------------ move occurs because `filename` has type `PathBuf`, which does not implement the `Copy` trait
...
189 |             create_dir_all(filename)?;
    |                            -------- value moved here
190 |             filename.push(format!("{outfile}.json"));
    |             ^^^^^^^^ value borrowed here after move
    |
help: consider cloning the value if the performance cost is acceptable
    |
189 |             create_dir_all(filename.clone())?;
    |                                    ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `translocate` (lib) due to previous error

On line 185 I create a mutable variable. Ownership of its data is moved into the create_dir_all function on line 189. Then I try to access that same data on line 190 but the data went out of scope when create_dir_all returned. The compiler (rustc) tells me I'm trying to borrow after the data referred to moved: can't do that! rustc also suggests a solution: pass in a copy (clone) of the data instead on line 189, then on line 190, that original data will still be in scope and available to be borrowed. rustc gives even more detailed help if you want it by running the command rustc --explain E0382.

This property of the language—helping you out with errors—made me want to actually write more rust. And the more I wrote, the better I got.

It's not only when your code has obviously broken the rules that the compiler is able to help you though. The rust community has invested in making new rust developers become better rust developers. As such there is standardized lint tooling built into cargo. This helps novice developers learn how to write better code by providing suggestions of more idiomatic ways to write code.

Here's an example of code I wrote to parse command line options

let output_filename = if let Some(path) = &args.output_filename {
	Some(path.as_str())
} else {
	None
};

output_filename above is an optional String and can be defined (Some<String>) or not (None). I want it as a string slice (&str) though, so new memory doesn't need to get allocated for it. You can see how I solved that problem above. It basically reads, if output_filename is defined give it back to me as a reference. Running cargo clippy against the program containing this code tells me the following:

 cargo clippy
warning: manual implementation of `Option::map`
   --> src/lib.rs:126:31
    |
126 |           let output_filename = if let Some(path) = &args.output_filename {
    |  _______________________________^
127 | |             Some(path.as_str())
128 | |         } else {
129 | |             None
130 | |         };
    | |_________^ help: try: `args.output_filename.as_ref().map(|path| path.as_str())`
    |
    = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#manual_map
    = note: `#[warn(clippy::manual_map)]` on by default

warning: `translocate` (lib) generated 1 warning (run `cargo clippy --fix --lib -p translocate` to apply 1 suggestion)
    Finished dev [unoptimized + debuginfo] target(s) in 0.19s

This lint essentially says, "Hey, what you're doing here manually, on this line, is the same thing that .map() could help you with. And here's an example of what I mean."

I go ahead and fix it, and this is the new code.

let output_filename = args.output_filename.as_ref().map(|path| path.as_str());

And since I've been trained that clippy is helpful, I run it again and am told:

 cargo clippy
warning: called `.as_ref().map(|path| path.as_str())` on an Option value. This can be done more directly by calling `args.output_filename.as_deref()` instead
   --> src/lib.rs:126:31
    |
126 |         let output_filename = args.output_filename.as_ref().map(|path| path.as_str());
    |                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try using as_deref instead: `args.output_filename.as_deref()`
    |
    = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#option_as_ref_deref
    = note: `#[warn(clippy::option_as_ref_deref)]` on by default

warning: `translocate` (lib) generated 1 warning (run `cargo clippy --fix --lib -p translocate` to apply 1 suggestion)
    Finished dev [unoptimized + debuginfo] target(s) in 0.16s

So, what was suggested earlier was better, but further improvements were possible. "Hey, the thing you want to do is so common we've added it to the standard library. Just call as_deref() and you're good." What I was trying to do had a function that would just do it and its use was suggested. Brilliant.

This empowers me to make a choice. I can keep the code I have because I'll make more changes in the closures, or I can make the change because it does exactly what I want. Either way, I've learned. I've been helped and that makes me both want to use the language more, and the tools it provides.

Building with rust

Over the course of time more experiences like that helped me decide to actually learn rust. I started off with something I had not seen in my search: a simple service to do form submissions.

For static sites I created, I typically had a PHP script that handled that form submissions. PHP can be a fickle mistress however. Security or OS updates can break PHP scripts in interesting ways. Even having a setup to test changes pre-deployment won’t necessarily make the actual task of fixing this breakage more pleasant. You'll only be notified sooner.

My grandfather used to say to me all the time, “Prevention is better than cure.” which has stuck wth me as a kind of personal mantra. Instead of debugging form submission bugs, what if I could avoid them? I believed the rust language had the best chance at this because it: had static compilation by default, was strongly typed, had built in testing capabilities, and good editor support. And so I started a project in rust, using the release candidate of a backend framework I with excellent documentation: send-contact-form.

After working on it for a while I changed the name to formulate (to create or devise methodically). That was after all the goal, to methodically create a form submission backend that would never fail me. And it didn’t, until it did. But only because I wasn’t quite as methodical as I could have been. I’d skipped writing tests 😬.

As soon as I started adding tests their utility was apparent. I would refactor things months after implementation and forget important details. But the tests, the tests would always remember. "This was your intent months ago, have you changed your mind? Why did you not tell us?" test failures said to me. Very rarely, I did change my mind, and the test had to be updated to reflect the new intent. But most often it was I who had forgotten. Rocket’s built in integration test setup, combined with tests as a first class concern in cargo made it so easy to opt in to tests. I was welcomed into using them, when I was ready.

The more tests I wrote, the more utility I got from them and the more I wanted to write them: that's what we call a virtuous cycle. I would have ideas for things I wanted to test but didn’t know how to, and so I looked into how to do those tests. I would implement them, and then asses the new more tested codebase. Then I might remove them because changes made in service of testing led to less readable code, or behavior I didn't like. But it was my choice. Through it all, I learned. It was while writing tests that I got used to lifetimes, and once I was comfortable with them, well, why not use them in my normal code too?

The feedback loop was such that I would write more code, learn more rust and add more features. Before I knew it, formulate was in a state where I considered it good enough to share with others. This in turn led to others contributing changes to make formulate even better. A surprise to me, but a welcome one. Another instance of the community helping others to improve their craft.

After that I had the opportunity to build another tool in JS which a POC was written in, or rust. I'll save the details of that project for another time, and instead say I chose to do that project in rust. I knew the end product, once it compiled would be rock solid, fast, and easy to test and debug. Those are the things I want in a language and rust as a langugage delivers. Rust is an amazing language but an even more amazing tool to get things done. Check it out, learn its ways, then enjoy the confidence that comes from writing code within the confines of the borrow checker.

1

Fast forward a few years later, I would do just that for an entirely different component library project.