I've been writing Rust on and off for a while now, long enough that I no longer reach for clone() in a blind panic every time the compiler raises an eyebrow. But I had a fortnight recently where the borrow checker and I genuinely fell out, and I think the way we made up is worth writing down, mostly because it's the same lesson every Rust person eventually learns and then smugly forgets they had to.
The task was small. I wanted a little in-memory graph: nodes, edges, the ability to walk from a node to its neighbours and back again. The sort of thing you'd knock out in twenty minutes in almost any language with garbage collection. In Rust it took me the better part of two evenings, and most of that was spent losing arguments.
the obvious design the compiler hated
My first instinct was the design I'd write in C++ or Java without thinking: a Node holds references to its neighbours.
struct Node<'a> {
id: u32,
neighbours: Vec<&'a Node<'a>>,
}
This compiles right up until you try to actually build a graph with it, at which point it falls apart in your hands. To put a reference to node B inside node A, node B has to already exist and outlive node A, and the moment you have a cycle (A points to B, B points to A) you have two things that each must outlive the other, which is a contradiction the borrow checker is entirely correct to reject. I spent an embarrassing amount of time trying to lifetime-annotate my way out of a problem that was, fundamentally, "this data structure cannot exist as described". The neighbours can't be borrowed references because nobody owns them in a way that survives.
I knew this, in the abstract. Knowing it in the abstract and recognising it at 11pm with eleven compiler errors on screen are different skills.
reaching for the wrong escape hatch
So I did what everyone does next. I reached for Rc<RefCell<Node>>, the classic "I want shared mutable graph nodes and I'd like the compiler to stop talking to me" combination.
use std::rc::Rc;
use std::cell::RefCell;
type Link = Rc<RefCell<Node>>;
struct Node {
id: u32,
neighbours: Vec<Link>,
}
And this works. It compiles, it runs, you can build cycles. For about an hour I felt clever. Then I remembered the catch, which is that Rc is reference counted and reference counting cannot collect cycles. My lovely bidirectional graph was a memory leak with extra steps. Every node in a cycle keeps every other node alive forever, because their counts never reach zero. The fix is Weak references for the back-edges, and suddenly half my code is .upgrade().unwrap() and the ergonomics have gone out of the window.
It worked. It was also miserable to use, and miserable to use is a code smell. The borrow checker hadn't gone away. I'd just bribed it, and the bribe had a running cost.
the design it wanted all along
The thing that finally clicked is that the borrow checker wasn't objecting to my graph. It was objecting to my ownership model. The nodes don't need to own each other at all. Something else can own all the nodes, and the nodes can refer to each other by index.
struct Graph {
nodes: Vec<Node>,
edges: Vec<(usize, usize)>,
}
struct Node {
id: u32,
}
The Graph owns everything. Nodes are just entries in a Vec, edges are pairs of indices into it. Want B's neighbours? Look them up by index. Cycles are no problem, because an index isn't a reference and doesn't keep anything alive, it's just a number. There's no Rc, no RefCell, no Weak, no .borrow_mut() dance. The compiler stopped complaining instantly, not because I'd appeased it but because the design was finally one it had no reason to object to.
It turns out this pattern has a name, the "arena" or "index-based graph", and the wider Rust ecosystem has been quietly using it for exactly this for years. I just hadn't met it yet, so I reinvented it badly twice first.
losing gracefully
Here's the part I actually want to keep. Every single time I lost an argument with the borrow checker over those two evenings, it was because I was trying to express an ownership model that didn't make sense, and the compiler was the only thing in the room honest enough to say so. The reference design had genuinely contradictory lifetimes. The Rc design genuinely leaked. The index design was genuinely sound, and the compiler genuinely waved it straight through.
That's the trick to losing gracefully against the borrow checker. Stop reading the error as "the compiler won't let me do the right thing" and start reading it as "the compiler thinks this is the wrong thing, and it's usually been doing this longer than I have". Nine times in ten, when I step back and ask "who actually owns this data, and for how long", the design that answers cleanly is also the one that compiles. The borrow checker isn't an obstacle between me and my design. Most of the time it's pointing at the better design I hadn't thought of yet.
I still lose. I just lose faster now, and I've learned to thank it afterwards.