I’ve written Ruby for most of my career and Rails still pays the bills. But these days a lot of my time goes into SolRengine, and Solana programs are written in Rust. So I learned Rust the way most people learn a second language: by needing it, with an accent.
I expected the famous learning curve. What I found was a language that borrowed a surprising amount from Ruby’s playbook — expressive iterators, everything-is-an-expression, great tooling — and then handed enforcement over to a compiler. This is the post I wish I’d read before starting: the translations that actually matter, side by side. Not a tutorial, a map.
Enumerable → Iterators
Good news first. If your Ruby leans on map, select and reduce, your Rust already reads fine:
orders.select(&:paid?)
.map(&:total)
.sum
orders.iter()
.filter(|o| o.paid)
.map(|o| o.total)
.sum::<u64>()
Blocks become closures — { |o| o.total } is |o| o.total — and almost everything in Enumerable has a counterpart: find is find, all? is all, each_slice is chunks, flat_map is flat_map.
Two differences hide in that snippet. Rust iterators are lazy: nothing runs until a consumer like sum or collect pulls values through, so a long chain doesn’t allocate an intermediate array at every step the way Ruby does. And occasionally you have to tell the compiler what type you want out, which is what the ::<u64> — the turbofish, genuinely its real name — is doing.
nil → Option
The biggest day-to-day shift. Every Ruby developer has shipped this:
user = User.find_by(email: email)
user.name # NoMethodError: undefined method 'name' for nil
The fix is remembering &. or an early return, and remembering is exactly the problem. Rust removes the failure mode instead: there is no nil. A value that might be absent has a different type, Option<User>, and you can’t call .name on it — the program doesn’t compile until you’ve handled both cases:
let user: Option<User> = find_user(email);
match user {
Some(u) => println!("{}", u.name),
None => println!("no user for {email}"),
}
The check you’d forget in Ruby isn’t optional in Rust. And Option already speaks your dialect: user.map(|u| u.name) is user&.name, and .unwrap_or(default) is || default.
rescue → Result
Same idea, applied to errors. Ruby raises and hopes someone downstream rescues:
def charge(order)
gateway.charge!(order.total) # who rescues Gateway::Error? grep and pray
end
Rust returns errors as plain values, and the signature declares what can go wrong:
fn charge(order: &Order) -> Result<Receipt, GatewayError> {
let receipt = gateway.charge(order.total)?;
Ok(receipt)
}
The ? is the whole trick: if charge failed, return the error to the caller right here; otherwise unwrap the value and keep going. It’s rescue => e; raise compressed into one character, except the compiler makes sure nobody up the chain can ignore it.
If you end up writing Anchor programs, this is the first Rust you’ll meet — every instruction handler returns a Result, and every failed constraint is an Err on its way back to the client.
Duck typing → Traits
Ruby doesn’t care what an object is, only what it responds to:
class Duck
def speak = "quack"
end
class Robot
def speak = "beep"
end
[Duck.new, Robot.new].each { |t| puts t.speak }
Rust wants the interface named. A trait is duck typing with a signature:
trait Speak {
fn speak(&self) -> String;
}
struct Duck;
impl Speak for Duck {
fn speak(&self) -> String { "quack".into() }
}
Here’s the part that surprised me: monkey patching survives the translation. In Ruby you reopen String; in Rust you implement your trait for a type you don’t own:
trait Shout {
fn shout(&self) -> String;
}
impl Shout for str {
fn shout(&self) -> String {
format!("{}!", self.to_uppercase())
}
}
"hola".shout() // "HOLA!"
The crucial difference is scope. A reopened String changes every string in the process, including inside your gems — which is why we invented refinements and then never used them. A trait impl only exists where the trait is used. It’s monkey patching with a blast radius of one file.
Bundler → Cargo
The shortest section, because there’s nothing to unlearn. Cargo.toml is your Gemfile, Cargo.lock is your lockfile, crates.io is rubygems.org. Dependencies resolve, versions pin, life goes on.
What’s different is how much lives in the one tool. cargo build, cargo test, cargo fmt, cargo clippy (a linter with strong opinions and good taste), cargo doc. That’s Bundler, Rake, minitest, RuboCop and YARD, one binary, zero configuration files to argue about. Ruby’s tooling is good; Cargo is what it looks like when it’s designed as a whole instead of accreted.
GC → Ownership
I saved the alien for last. Everything above maps onto something you already know. Ownership doesn’t, and pretending otherwise is how “Rust is easy, actually” posts lose their readers.
In Ruby, every variable is a reference, share them freely, GC sweeps up later. In Rust, every value has exactly one owner, and assignment moves ownership:
let a = String::from("hola");
let b = a;
println!("{a}"); // error[E0382]: borrow of moved value: `a`
That error made no sense to my Ruby brain — I just assigned a variable, a thing I have done millions of times. But it’s the entire deal: because the compiler always knows the single owner of every value, it knows exactly when memory can be freed, and you get GC-level safety with no GC running. Everything else — & borrows, lifetimes, clone() as the honest escape hatch — follows from that one rule.
I won’t teach the borrow checker in four paragraphs. I’ll just say the errors are better tutors than they get credit for: they point at the exact line, explain the conflict, and usually suggest the fix. You learn ownership by arguing with the compiler and losing, repeatedly, until one day you stop having the argument.
The compiler is a pairing partner
The theme by now is obvious. Most of what Rust does is take a discipline good Ruby developers already practice — check your nils, handle your errors, define your interfaces, don’t patch what you don’t own — and refuse to compile until it’s actually done. Ruby trusts you and lets you move fast; Rust distrusts you and lets you sleep.
I still reach for Ruby when I want to sketch. But “if it compiles, it works” turned out not to be a meme, and for programs where a bug moves someone’s money — which is what a Solana program is — that’s exactly the trade I want.
If you want to see the Rust side in production use, the Anchor series starts from hello world and ends at a working Dutch auction.