Home others Prototyping in Rust
others - January 15, 2025

Prototyping in Rust

Prototyping in Rust

Programming is an iterative process – as much as we would like to come up with the perfect solution from the start, it rarely works that way.

Good programs often start as quick prototypes.
The bad ones stay prototypes, but the best ones evolve into production code.

Whether you’re writing games, CLI tools, or designing library APIs, prototyping helps tremendously in finding the best approach before committing to a design.
It helps reveal the patterns behind more idiomatic code.

For all its explicitness, Rust is surprisingly ergonomic when iterating on ideas. Contrary to popular belief, it is a joy for building prototypes.

You don’t need to be a Rust expert to be productive – in fact, many of the techniques we’ll discuss specifically help you sidestep Rust’s more advanced features.
If you focus on simple patterns and make use of Rust’s excellent tooling, even less experienced Rust developers can quickly bring their ideas to life.

Things you’ll learn

  • How to prototype rapidly in Rust while keeping its safety guarantees
  • Practical techniques to maintain a quick feedback loop
  • Patterns that help you evolve prototypes into production code

Why People Think Rust Is Not Good For Prototyping

The common narrative goes like this:

When you start writing a program, you don’t know what you want and you change your mind pretty often.
Rust pushes back when you change your mind because the type system is very strict.
On top of that, getting your idea to compile takes longer than in other languages, so the feedback loop is slower.

I’ve found that developers not yet too familiar with Rust often share this preconception.
These developers stumble over the strict type system and the borrow checker while trying to sketch out a solution.
They believe that with Rust you’re either at 0% or 100% done (everything works and has no undefined behavior) and there’s nothing in between.

Here are some typical misbeliefs:

  1. “Memory safety and prototyping just don’t go together.”
  2. “Ownership and borrowing take the fun out of prototyping.”
  3. “You have to get all the details right from the beginning.”
  4. “Rust always requires you to handle errors.”

These are all common misconceptions and they are not true.

It turns out you can avoid all of these pitfalls and still get a lot of value from prototyping in Rust.

Problems with Prototyping in Other Languages

If you’re happy with a scripting language like Python, why bother with Rust?

That’s a fair question!
After all, Python is known for its quick feedback loop and dynamic type system, and you can always rewrite the code in Rust later.

Yes, Python is a great choice for prototyping.
But I’ve been a Python developer for long enough to know that I’ll very quickly grow out of the “prototype” phase
-– which is when the language falls apart for me.

One thing I found particularly challenging in Python was hardening my prototype into a robust, production-ready codebase.
I’ve found that the really hard bugs in Python are often type-related: deep down in your call chain, the program crashes because you just passed the wrong type to a function.
Because of that, I find myself wanting to switch to something more robust as soon as my prototype starts to take shape.

The problem is that switching languages is a huge undertaking – especially mid-project.
Maybe you’ll have to maintain two codebases simultaneously for a while.
On top of that, Rust follows different idioms than Python, so you might have to rethink the software architecture.
And to add insult to injury, you have to change build systems, testing frameworks, and deployment pipelines as well.

Wouldn’t it be nice if you could use a single language for prototyping and production?

What Makes Rust Great for Prototyping?

Using a single language across your entire project lifecycle is great for productivity.
Rust scales from proof-of-concept to production deployment and that eliminates costly context switches and rewrites.
Rust’s strong type system catches design flaws early, but we will see how it also provides pragmatic escape hatches if needed.
This means prototypes can naturally evolve into production code;
even the first version is often production-ready.

But don’t take my word for it. Here’s what Discord had to say about migrating from Go to Rust:

Remarkably, we had only put very basic thought into optimization as the Rust version was written. Even with just basic optimization, Rust was able to outperform the hyper hand-tuned Go version. This is a huge testament to how easy it is to write efficient programs with Rust compared to the deep dive we had to do with Go.
– From Why Discord is switching from Go to Rust

What A Solid Rust Prototyping Workflow Looks Like

If you start with Rust, you get a lot of benefits out of the box:
a robust codebase, a strong type system, and built-in linting.

All without having to change languages mid-project!
It saves you the context switch between languages once you’re done with the prototype.

flow

Python has a few good traits that we can learn from:

  • fast feedback loop
  • changing your mind is easy
  • it’s simple to use (if you ignore the edge cases)
  • very little boilerplate
  • it’s easy to experiment and refactor
  • you can do something useful in just a few lines
  • no compilation step

The goal is to get as close to that experience in Rust as possible while staying true to Rust’s core principles.
Let’s make changes quick and painless and rapidly iterate on our design without painting ourselves into a corner.
(And yes, there will still be a compilation step, but hopefully, a quick one.)

Tips And Tricks For Prototyping In Rust

Use simple types

Even while prototyping, the type system is not going away.
There are ways to make this a blessing rather than a curse.

Use simple types like i32, String, Vec in the beginning.
We can always make things more complex later if we have to – the reverse is much harder.

Here’s a quick reference for common prototype-to-production type transitions:

Prototype Production When to switch
String &str When you need to avoid allocations or store string data with a clear lifetime
Vec<T> &[T] When the owned vector becomes too expensive to clone or you can’t afford the heap
Box<T> &T or &mut T When Box becomes a bottleneck or you don’t want to deal with heap allocations
Rc<T> &T When the reference counting overhead becomes too expensive or you need mutability
Arc<Mutex<T>> &mut T When you can guarantee exclusive access and don’t need thread safety

These owned types sidestep most ownership and lifetime issues, but they do it by allocating memory on the heap – just like Python or JavaScript would.

You can always refactor when you actually need the performance or tighter resource usage, but chances are you won’t.[1]

Make use of type inference

Rust is a statically, strongly typed language.
It would be a deal-breaker to write out all the types all the time if it weren’t for Rust’s type inference.

You can often omit (“elide”) the types and let the compiler figure it out from the context.

let x = 42;
let y = "hello";
let z = vec![1, 2, 3];

This is a great way to get started quickly and defer the decision about types to later.
The system scales well with more complex types, so you can use this technique even in larger projects.

let x: Vec<i32> = vec![1, 2, 3];
let y: Vec<i32> = vec![4, 5, 6];

// From the context, Rust knows that `z` needs to be a `Vec<i32>`
// The `_` is a placeholder for the type that Rust will infer
let z = x.into_iter().chain(y.into_iter()).collect::<Vec<_>>();

Here’s a more complex example which shows just how powerful Rust’s type inference can be:

use std::collections::HashMap;

// Start with some nested data
let data = vec![
    ("fruits", vec!["apple", "banana"]),
    ("vegetables", vec!["carrot", "potato"]),
];

// Let Rust figure out this complex transformation
// Can you tell what the type of `categorized` is?
let categorized = data
    .into_iter()
    .flat_map(|(category, items)| {
        items.into_iter().map(move |item| (item, category))
    })
    .collect::<HashMap<_, _>>();

// categorized is now a HashMap<&str, &str> mapping items to their categories
println!("What type is banana? {}", categorized.get("banana").unwrap());

(Playground)

It’s not easy to visualize the structure of categorized in your head, but Rust can figure it out.

Use the Rust playground

You probably already know about the Rust Playground.
The playground doesn’t support auto-complete, but it’s still great when you’re on the go or you’d like to share your code with others.

I find it quite useful for quickly jotting down a bunch of functions or types to test out a design idea.

Use unwrap Liberally

It’s okay to use unwrap in the early stages of your project.
An explicit unwrap is like a stop sign that tells you “here’s something you need to fix later.”
You can easily grep for unwrap and replace it with proper error handling later when you polish your code.
This way, you get the best of both worlds: quick iteration cycles and a clear path to robust error handling.
There’s also a clippy lint that points out all the unwraps in your code.

use std::fs;
use std::path::PathBuf;

fn main() {
    // Quick and dirty path handling during prototyping
    let home = std::env::var("HOME").unwrap();
    let config_path = PathBuf::from(home).join(".config").join("myapp");
    
    // Create config directory if it doesn't exist
    fs::create_dir_all(&config_path).unwrap();
    
    // Read the config file, defaulting to empty string if it doesn't exist
    let config_file = config_path.join("config.json");
    let config_content = fs::read_to_string(&config_file)
        .unwrap_or_default();
    
    // Parse the JSON config
    let config: serde_json::Value = if !config_content.is_empty() {
        serde_json::from_str(&config_content).unwrap()
    } else {
        serde_json::json!({})
    };
    
    println!("Loaded config: {:?}", config);
}

See all those unwraps?
To more experienced Rustaceans, they stand out like a sore thumb – and that’s a good thing!

Compare that to languages like JavaScript which can throw exceptions your way at any time.
It’s much harder to ensure that you handle all the edge-cases correctly.
At the very least, it costs time. Time you could spend on more important things.

While prototyping with Rust, you can safely ignore error handling and focus on
the happy path without losing track of improvement areas.

Use a good IDE

There is great IDE support for Rust.

IDEs can help you with code completion and refactoring, which keep you in the flow and help you write code faster.
Autocompletion is so much better with Rust than with dynamic languages because the type system gives the IDE a lot more information to work with.

As a corollary to the previous section, be sure to use enable inlay hints (or inline type hints) in your editor.
This way, you can quickly see the inferred types right inside your IDE and make sure the types match your expectations.
There’s support for this in most Rust IDEs, including RustRover and Visual Studio Code.

Inlay hints in Rust Rover

Use bacon for quick feedback cycles

Rust is not a scripting language; there is a compile step!

However, for small projects, the compile times are negligible.
Unfortunately, you have to manually run cargo check every time you make a change
or use rust-analyzer in your editor to get instant feedback.

To fill the gap, you can use external tools like bacon which automatically recompiles and runs your code whenever you make a change.
This way, you can get almost the same experience as with a REPL in, say, Python or Ruby.

The setup is simple:

# Install bacon
cargo install --locked bacon

# Run bacon in your project directory
bacon

And just like that, you can get some pretty compilation output alongside your code editor.

bacon

Oh, and in case you were wondering, cargo-watch was another popular tool for
this purpose, but it’s since been deprecated.

cargo-script is awesome

Did you know that cargo can also run scripts?

For example, put this into a file called script.rs:

#!/usr/bin/env cargo +nightly -Zscript

fn main() {
    println!("Hello prototyping world");
}

Now you can make the file executable with chmod +x script.rs and run it with ./script.rs which it will compile and execute your code!
This allows you to quickly test out ideas without having to create a new project.
There is support for dependencies as well.

At the moment, cargo-script is a nightly feature, but it will be released soon on stable Rust.
You can read more about it in the RFC.

Don’t worry about performance

You have to try really really hard to write slow code in Rust.
Use that to your advantage: during the prototype phase, try to keep the code as simple as possible.

I gave a talk titled “The Four Horsemen of Bad Rust Code” where I
argue that premature optimization is one of the biggest sins in Rust.

Especially experienced developers coming from C or C++ are tempted to optimize too early.

Rust makes code perform well by default – you get memory safety at virtually zero runtime cost. When developers try to optimize too early, they often run up against the borrow checker by using complex lifetime annotations and intricate reference patterns in pursuit of better performance.
This leads to harder-to-maintain code that may not actually run faster.

Resist the urge to optimize too early!
You will thank yourself later. [2]

Use println! and dbg! for debugging

I find that printing values is pretty handy while prototyping.
It’s one less context switch to make compared to starting a debugger.

Most people use println! for that, but dbg! has a few advantages:

  • It prints the file name and line number where the macro is called. This helps you quickly find the source of the output.
  • It outputs the expression as well as its value.
  • It’s less syntax-heavy than println!; e.g. dbg!(x) vs. println!("{x:?}").
  • It’s only active in debug builds, so it has no performance impact for releases.

Where dbg! really shines is in recursive functions or when you want to see the intermediate values during an iteration:

fn factorial(n: u32) -> u32 {
    // `dbg!` returns the argument, 
    // so you can use it in the middle of an expression
    if dbg!(n <= 1) {
        dbg!(1)
    } else {
        dbg!(n * factorial(n - 1))
    }
}

dbg!(factorial(4));

The output is nice and tidy:

[src/main.rs:2:8] n <= 1 = false
[s

Author Of article : Matthias Endler

Read full article

Leave a Reply

Your email address will not be published. Required fields are marked *

three × four =

Check Also

Top individual investors see mixed fortunes in December quarter

India's top investors experienced mixed results in the December quarter. While some po…