Yeah, Rust is low-key goated.
Thoughts on learning Rust by making a little project and using a little WASM.
Every 3 months at the company All-Hands an intern asks the CTO if we can “rewrite our entire codebase in Rust?” And every time I wonder, what the heck is Rust.
I wanted to relate to my fellow interns so I decided to learn Rust. The following are thoughts from someone who writes way too much TypeScript and Python attempting to learn Rust over the past ~2 months. Strap in, it’s gonna be a real gathering of the minds.
If you just want to see what I built, and the code for it:
Foundations.
Rust makes getting started extremely easy. Run the terminal command on rustup and the Rust toolchain will be installed. Rust is a fairly mature language with a rich amount of high quality open-source educational material. I learnt most of the fundamentals using the Rust Programming Language book and Rust By Examples (both free online resources).
If you’re getting started, I recommend reading the first 4 chapters of the Programming Language book and then trying to build something yourself. When you come across an error or new concept, CTRL+F between the ebook book and the examples website.
Why build a “Redis clone”?
The only way to learn a language is to try and build something using it. Advanced concepts such as the “Rust borrow checker” aren’t going to make sense just by reading over some example code.
To me, Redis is kind of the perfect tool to try and recreate. In the real world it’s used as a caching system / data store / queuing system. But at its core: you give Redis a string → it either gets or changes some data → it returns some data. Easy.
This core functionality of Redis is simple enough that it can be turned into a straightforward coding interview question. You can have a basic solution in < 30 lines of JS. A bit of string parsing, mutate some objects, and return a string or number. Simple. But what if you want to add on complexity. How would you create a system that neatly handles 40+ commands? How would you handle creating a server that can execute these commands over HTTP? What about data persistence and importing / exporting data? Replicating data across nodes? What about making the same code run both on the CLI and on the web (spoiler alert)?
That’s why I kind of love Redis. It’s so simple yet you can choose to learn new things just by adding complexity.
So I decided to build a Redis CLI tool with the goal of learning:
Fundamentals of Rust
How it handles basic user input and output
How to manipulate and pass around data
How Rust handles inheritance for different commands
Finally, can we get Rust to run on the web
Here’s a list of things I learnt while working on my project, roughly in the order that I came across them.
A compiler that tells you what’s wrong, and how to fix it.
Like C++, Rust is a complied language. Which means there is a compiler involved. (Gathering of the minds, I told you). I’ve done a lot of C++ in my University days and I don’t miss seg faults, weird memory pointer issues (turns out it was actually a skill issue), super verbose typing, and using gdb at 1am. How much of that spills over into the Rust world?
Well, let’s start with the compiler. When you run `cargo run` it’ll build and run your code. This same command is when any build time issues will popup. And popup they will.
We’ll talk later about what this error actually means, but notice the fact that there is a “help” section where it tells you what you need to add (in the green text). This is a common trend with learning Rust: write a thing → compiler error → learn new concept → implement fix based on compiler error.
It’s typed sensibly.
The neat thing about Rust is that the language does a great job inferring the types. In fact, it does it much better than TypeScript.
Consider this TypeScript code (TS playground link):
Notice how we have a function returning a DataType. We have a map called `data` we return but `data` still has “any” as both the inferred key and value. It’s unable to infer that the `data` should be `Map<string, number>` based on the return type of the function.
Now, let’s see Rust in action (Git codelink):
Same exact function here but written in Rust. I didn’t actually add typing to `data` but it was correctly inferred `HashMap<String, i64>` based on the return type of the function. (The only reason you see it in gray is because the VSCode Rust plugin shows inferred types).
Amazing. This makes so much sense. This was a consistent theme while learning Rust: it made me realize TypeScript is the Temu typing system. Actually, that’s probably Python. We don’t need to talk about Python typing.
Protect you from you.
A major selling point of Rust is safety when it comes to memory access. C++ lets you pass around pointers and do whatever you want with them. Want to cast a Dog pointer into a Chair pointer? Cool, go for it. Turn a Train class object into a List of Dolphins? Sure. In C++ when you handoff a pointer in a function call, anything is possible. I think this is partially where the “let’s re-write it in Rust” meme comes from. Rust is much more strict about the way data is passed around and reassigned. It’s safer.
Part of what enables this memory safety is the Rust borrow checker. This is one system that can guarantee safety, but it comes at a cost: the learning curve.
Let’s see it in action. Let’s create a function called `execute` that pints out key and value.
fn execute(&self, _: &mut DataType) {
let key = self.key;
println!("{}", key)
}
First we add key. Then we copy and paste the key line and change the “key” to value” for something like this:
Wait, what’s this? We’re getting an error. I have an intentional typo where I forgot to change the second `self.key` to `self.value`. The type of bug speedy vim programmers make all the time. Because we’ve assigned `key` already, the borrow checker will guard against this kind of behavior. Neato!
You’ve seen `structs` and `enums` before. But not like this.
Instead of telling you, why don’t I show you. For our Redis clone, I want to handle 3 commands (I call actions):
GET: get a value
SET: set a value
INCR : increase a numerical value by 1
Since only one user action is possible at a time, let’s create an Action enum:
pub enum Action {
GetAction(Get),
SetAction(Set),
IncrAction(Incr),
}
We can actually combine an enum and struct together in a way I haven’t seen in other languages. In our case, when we execute the GET command we input something like “GET coffee_shop_name”. Or we might execute “SET employee_count 12”. Each action has some associated data with it. Let’s use structs to define our 3 actions:
pub struct Get(pub String); // GET key
pub struct Incr(pub String); // INCR key
pub struct Set { // SET key value
pub key: String,
pub value: String,
}
Great, now we know the action the user is doing (like “SET”) and we know the value for that action (key and value). Now, how do we actually “do” stuff using this action. That’s where traits and implementations come into play.
We want every Redis command to follow the same blueprint. In most languages we’d have an “Action” class and then each action (like SET or GET) would be a subclass of that. In Go we would have interfaces. In Rust we achieve this by having traits. Here’s the trait that defines the Redis action:
pub trait ActionTrait {
// Execute mutates the data
fn execute(&self, data: &mut DataType);
// Print the final response
fn print(&self, data: &DataType);
}
The trait requires the implementation to have two methods.
An execute() method that gets passed a mutable data object. This is where SET will add to the data or INCR will update the numerical value by one.
A print() method that prints some result of the command. Some commands return “OK” other’s (such as GET) return an actual value. We could’ve had just one execute() method but I decided to split the “do stuff to data” and “return stuff to the screen” logic. It’ll help with the later WASM section.
Now, we want to implement the blueprint for the SET command. Here’s how we do it:
Incredible stuff. First we’ll run execute() to add the key/value to our data data store, then we’ll run print() to print out “OK” (which is what Redis does when you SET a value). I’ve placed the files for the actions in their own dedicated /actions folder. Can you guess what the GET command’s execute() method does?
The `match` statement:
Now that we have a folder full of command action files, we need a way to run them. Let’s break it down:
User passes in a string like “SET coffee_shop_name Sunrise Coffee”
We parse the user input string into an Action
Based on the Action, we execute and print the results.
Here’s the code for these steps:
One thing Rust does really well is: correctly handling result. Quite often we write code where we either return a value or we return an error. Different languages handle this differently. Rust handles this using the Result type. A Result consists of some data you return via `Ok(value)` or you return an error via `Err(E)`. Simple.
Now let’s walk through the above code:
First we’re parsing the Action from the user string using a utility function I wrote called parse_action_from_user_string()
Notice how the return type is a Result. Either the user’s input is a valid action, or we’ll return an error.
Next we use a match statement. Just think of it as a switch statement on steroids (I’ll expand on this shortly).
In the OK case we correctly parsed a valid action from the user provided string.
Next we have another match statement. This one matches on the specific actions (GET or SET or INCR).
Either run action.execute() or action.print() for GET, SET, INCR. Notice that there is no execute() called for GET because the GET command doesn’t actually change any data. It just prints a string.
For our root match statement we also have an Err arm for when the user provided string didn’t yield a valid action. Maybe the user input asked for an action we haven’t implemented yet or there was a typo in the string. Who knows.
Stellar! That’s basically the CLI implementation of the Redis CLI core.
I do want to mention that the `match` expression operates in a much powerful way compared to a JS or Python switch statement. Let’s consider this TypeScript code that uses a switch statement (playground):
Notice how the switch statement only considers one case (the MouseDown case). We completely disregarded MouseUpOutside and MouseUpInside. TypeScript doesn’t have an issue with this.
With `match`, every case of the enum needs to be accounted for. Think about how great that is: if I want to add a new command, I add it to the Action enum and at build time the Rust compiler will tell me that there are forks in the code I have to update.
Also, notice how the Result type is being matched against at the root. This is like being able to switch on errors. In other languages we’d need to have some kind of a try/catch mechanism. Oh yeah, and good luck properly propagating the caught error to your observability tool like Sentry. Also guess what happens if I delete the Err arm of the root match statement?
That’s right, a compiler error. This means that match combined with Result forces you to always consider the path with errors. Every JavaScript developer is shrieking.
Compiling executables for the web?
Sounds like a fever dream.
Web-assembly (or WASM) could probably be it’s own blog post (wink wink). The one thing I’ll say is that the WASM + Rust toolchain is really solid. I used wasm-pack to build the WASM binary artifacts. Copied them over to my frontend’s src/pkg folder and used it in my React component right here:
// Import the artifacts from wasm-pack:
import * as wasmPkg from "../pkg";
// Init the wasm class:
const wasm = useMemo(() => new wasmPkg.WasmRunnerContainer(), []);
// Run the run method on my class:
const output = wasm.run("SET coffe_shop_name Sunrise Coffee");
This is powerful stuff. We’re now taking user input on a website → passing it to a compiled module written in Rust → returning a string we show again on a web app.
Some parting thoughts.
So, should we create a JIRA ticket to re-write the entire company in Rust? Well there’s probably some wisdom in the I love building a startup in Rust. I wouldn’t pick it again article.
I think Rust is really impressive. It’s both challenging and fulfilling to learn. The ecosystem and developer experience feels top-of-class. It has shown me the light of a brilliantly engineered typing system. But it comes at the cost of moving slow and a steep learning curve. If you are bootstrapping your own startup or work at a “intense and fast paced” company where changes should’ve gone live yesterday, then stick with more forgiving languages. Tradeoffs to consider. Ultimately it’s your call. I’m just a boy with a Substack.
Also, Jon Gjengset’s YouTube channel and his book are an incredible resource and part of the reason I wanted to learn Rust in the first place. He has the best “advanced” programming YouTube content out there IMO.
There are many things such as macros, fearless concurrency, the module system, and advanced types such as Box and RefCell which I simply didn’t get into. Maybe next time 🤷🏿.
Edit: if anyone has a “I <3 the Rust match Statement” t-shirt please get in touch.