Ownership and Borrowing in Rust Explained Simply
I break down Rust's ownership model — how single ownership, move semantics, and borrows (&T and &mut T) prevent memory bugs that other languages catch too late.

You do something completely normal — assign a variable to another variable — and Rust won’t compile. The error says borrow of moved value: s1. You’ve seen hundreds of compile errors before. This one doesn’t make sense in the same way.
That’s the borrow checker introducing itself. It isn’t enforcing style or warning about a bad pattern. It’s enforcing a rule about memory that most languages don’t have: one value, one owner, at any given time.
Once you understand why that rule exists, the borrow checker stops feeling like a wall and starts feeling like the thing that makes Rust’s “no runtime memory bugs” guarantee actually work. I’m going to walk through ownership, borrowing, and the basics of lifetimes — with concrete code examples drawn from The Rust Programming Language — and explain each concept from the perspective of someone who already thinks in TypeScript.
Series: Part 3 of 12. Start with Part 1: Learning Rust as a TypeScript Developer · Previous: Setting Up Rust, Cargo, and VS Code
On this page
- One value, one owner — the rule that changes everything
- What “move” actually means for heap data
- Borrow a value without taking ownership of it
- Mutable borrows and why you can only have one
- The mistakes that confuse TypeScript developers most
- Lifetimes: what the borrow checker is checking under the hood
- What clicks once the model sticks
One value, one owner — the rule that changes everything
The Rust Book states three ownership rules that govern every value in the language:
- Each value has exactly one owner.
- Only one owner can exist at a time.
- When the owner goes out of scope, the value is dropped.
The third rule is the practical payoff. In JavaScript and TypeScript, a garbage collector tracks which values are still reachable and cleans up the rest. In Rust there’s no garbage collector. The compiler determines — at compile time — exactly when each value goes out of scope, and inserts the cleanup code automatically.
{
let s = String::from("hello"); // s is valid from this point
// do things with s here
} // this scope ends here; s is dropped, its heap memory freed automatically
A special function called drop runs at the closing brace. No manual free(). No GC scan. The compiler knows when to insert it because ownership is tracked statically — the same way TypeScript’s type checker knows a variable’s type without running the code.
If you’re coming from JavaScript where let and const also have block scope, this feels familiar on the surface. The difference is that in Rust, going out of scope doesn’t just make the name unavailable — it actually frees the memory the value was using. That’s the whole deal.
What “move” actually means for heap data
This is where TypeScript intuitions break first. In TypeScript, assigning an object to a new variable gives you two names for the same object:
const a = { name: "hello" };
const b = a; // both a and b reference the same object in memory
In Rust, when you assign a String to another variable, the original variable becomes invalid:
let s1 = String::from("hello");
let s2 = s1; // s1 is moved into s2
println!("{s1}"); // ERROR: borrow of moved value: `s1`
This is called a move. Ownership of the string’s heap data transferred from s1 to s2. After the move, s1 is gone from Rust’s perspective — using it is a compile error.
Why not just copy? A String is heap-allocated. If Rust let both s1 and s2 own the same heap memory, both would try to free it when their scope ends. That’s a double-free — a classic memory bug that crashes programs and creates security vulnerabilities. Rust prevents it by invalidating the original: when s1 is assigned to s2, s1 ceases to exist.
If you genuinely need both, you call .clone():
let s1 = String::from("hello");
let s2 = s1.clone(); // deep copy: separate allocation, both variables valid
println!("s1 = {s1}, s2 = {s2}"); // works fine
The .clone() call is a deliberate signal — it means “something potentially expensive is happening here.”
Stack-only types behave differently. Simple values like integers, booleans, and floats live entirely on the stack with a fixed, known size at compile time. These types implement the Copy trait, which means assignment copies the value without invalidating the original:
let x = 5;
let y = x; // x is copied, not moved — Copy types have no heap allocation to worry about
println!("x = {x}, y = {y}"); // both are valid
The rule is: if a type implements Copy (integers, booleans, floats, chars, and tuples of those), assignment copies it. If it doesn’t (like String, Vec<T>, or any heap-owning type), assignment moves it.
This maps neatly onto something you already know from understanding JavaScript scope and closures: the distinction between values that live on the stack and references that point elsewhere. Rust just makes that distinction explicit and enforces it through the ownership system.
Borrow a value without taking ownership of it
Moving a value into a function makes sense when the function should own it permanently. Most of the time you just want to read a value — you don’t want to give it away. That’s what references are for.
A reference lets you use a value without taking ownership. The & syntax creates an immutable reference:
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // pass a reference, not the value itself
println!("The length of '{s1}' is {len}."); // s1 is still valid here
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s goes out of scope, but since it never owned the String, nothing is dropped
The &s1 syntax creates a reference that points to the value of s1 without taking ownership. When calculate_length returns, s goes out of scope — but the String isn’t dropped, because s was never the owner.
The Rust Book calls this borrowing: like borrowing something from a friend, you get to use it and you give it back. The original owner keeps ownership throughout.
You can have as many immutable references to a value as you want at the same time — multiple readers can coexist because none of them can change anything.
Before you continue: What do you think happens if you try to modify a
Stringthrough an immutable reference&String?
The answer: it doesn’t compile. Immutable references are read-only by definition — the type system enforces it. To modify a borrowed value, you need a different kind of reference entirely.
Mutable borrows and why you can only have one
To modify a borrowed value, you need a mutable reference — &mut T:
fn main() {
let mut s = String::from("hello"); // s must be declared mut
change(&mut s);
println!("{s}"); // prints "hello, world"
}
fn change(s: &mut String) {
s.push_str(", world");
}
Note that s must be declared with mut first. You can’t take a mutable reference to an immutable variable — the two mut markers are both required.
Here’s the restriction that surprises almost every developer coming from another language: you can have exactly one mutable reference to a value at a time, and no immutable references can exist at the same time.
This fails:
let mut s = String::from("hello");
let r1 = &s; // immutable borrow — fine
let r2 = &s; // another immutable borrow — still fine
let r3 = &mut s; // ERROR: cannot borrow as mutable while immutable borrows are active
println!("{r1}, {r2}, and {r3}");
And this also fails:
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // ERROR: cannot borrow `s` as mutable more than once at a time
println!("{r1}, {r2}");
Why the restriction? A mutable reference is a guarantee that nothing else is observing the data while you’re changing it. If you had both an immutable and a mutable reference alive simultaneously, the code reading through the immutable reference could see the data change unexpectedly mid-read. That’s a data race — the kind that causes undefined behavior in C++ and hard-to-diagnose bugs in concurrent code. Rust prevents data races at compile time, not at runtime.
The rule to memorize: at any given time, you can have either many immutable references or exactly one mutable reference — never both.
If you’ve read through how TypeScript’s type checker works, you can see the pattern: both systems are giving the compiler enough information to flag a class of bugs before the code ever runs. Types prevent shape errors; the borrow checker prevents memory errors. Same idea, different layer.
The mistakes that confuse TypeScript developers most
Using a value after moving it into a function.
fn take_ownership(s: String) {
println!("{s}");
}
let s = String::from("hello");
take_ownership(s);
println!("{s}"); // ERROR: value moved into take_ownership, no longer valid here
The fix is usually to pass a reference instead of the value itself:
fn read_only(s: &String) {
println!("{s}");
}
let s = String::from("hello");
read_only(&s); // borrow, not move
println!("{s}"); // s is still valid
Misreading the scope of a borrow.
The borrow checker tracks reference scopes by their last use, not by block boundaries — this is called Non-Lexical Lifetimes. In practice, this code compiles fine:
let mut s = String::from("hello");
let r1 = &s;
println!("{r1}"); // last use of r1 — its borrow ends here
let r2 = &mut s; // no conflict: r1's scope already ended
println!("{r2}");
The borrow checker is smarter than you might expect: it doesn’t hold a reference hostage until the end of the block. But if you try to use r1 after creating r2, it’ll still refuse. The rule is about overlapping usage, not overlapping declaration.
Thinking assignment shares a reference like TypeScript.
When you write let s2 = s1 in TypeScript for an object, both names reference the same heap value. In Rust, for any non-Copy type, that assignment moves the value — s1 is gone. If you want two usable references, you want &s1, not a reassignment.
Check this before moving on
- You can explain why
let s2 = s1for aStringmakess1invalid - You know why multiple
&Treferences are allowed but only one&mut Tis - You can identify when to pass
value,&value, or&mut valueto a function
Lifetimes: what the borrow checker is checking under the hood
Every reference in Rust has a lifetime — the scope for which it’s guaranteed to be valid. Most of the time lifetimes are inferred automatically and you never write them. But occasionally the compiler can’t figure out the relationship between reference lifetimes on its own, and you annotate them explicitly.
The classic case is a function that returns a reference derived from its inputs:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
The 'a annotation says: “the returned reference will be valid for at least as long as both x and y are valid.” Without it, the borrow checker can’t determine whether the returned reference might outlive the data it points to.
This is the core thing lifetimes prevent: dangling references — pointers to memory that has already been freed. In C, this is undefined behavior. In Rust, it’s a compile error.
fn dangle() -> &String { // ERROR: missing lifetime specifier
let s = String::from("hello");
&s
} // s is dropped here — the returned reference would point to freed memory
Rust refuses to compile this because the reference would immediately become invalid. The fix is to return the String directly (transferring ownership) rather than a reference to a local variable.
Most everyday Rust doesn’t need explicit lifetime annotations because of lifetime elision — the compiler applies a set of rules to infer lifetimes from common patterns. A function that takes one &str and returns a &str of the same type gets lifetimes inferred automatically.
The mental model that matters most: lifetimes are the borrow checker’s proof that every reference you use points to data that’s still alive. When the compiler asks you for a lifetime annotation, it’s saying “I can’t figure out the relationship between these reference lifetimes on my own — help me.”
What clicks once the model sticks
Ownership isn’t a restriction imposed on top of normal programming. It’s a replacement for the garbage collector.
The rules exist because Rust removed GC and replaced it with something the compiler can reason about at compile time. Each rule directly prevents a class of memory bug: single ownership prevents double-free, move semantics prevent use-after-free on moved values, the borrow rules prevent data races, and lifetime checks prevent dangling references.
If you’re coming from TypeScript, you already have the right mental habit: you think about types statically. Ownership is that same habit applied one level down, to memory rather than to shapes. The error[E0382]: borrow of moved value message is Rust’s equivalent of TypeScript’s Type 'string' is not assignable to type 'number' — a pre-run catch of something that would go wrong at runtime in a language without the check.
One practical starting point: before passing a value into a function, decide what that function should do with it.
| You want the function to | Use this |
|---|---|
| Own the value permanently | Pass the value directly |
| Read it without modifying | Pass &value |
| Modify it without owning | Pass &mut value |
That single decision at each call site is the majority of what the borrow checker needs from you. The rest — lifetimes, complex borrow interactions — surfaces gradually as you write more Rust. The ownership model doesn’t get easier by reading more about it. It gets easier the first time the borrow checker catches a real bug you were about to ship.