Rust Deep Dives #2: Deref Coercion: Why &String Silently Becomes &str
Post 2 of 8 in Rust Deep Dives. Companion series: Rust Patterns That Matter.
Previous: #1: HashMap's Entry API | Next: #3: AsRef, Borrow, and ToOwned
Rust is famous for being explicit. You annotate lifetimes, you choose between
& and &mut, you spell out every type conversion. Except
for one place where the compiler quietly does work behind your back, and it happens so
often that most Rust programmers never notice it. That place is deref coercion.
The mystery
Here is a function that takes a &str:
fn greet(name: &str) {
println!("Hello, {name}!");
}
And here is the call site:
let s = String::from("Alice");
greet(&s);
Stop and think about the types. s is a String.
&s is a &String. But the function wants a
&str. Those are different types. A &String is a
reference to a heap-allocated, growable buffer. A &str is a fat pointer
to a UTF-8 byte slice. And yet this compiles and runs without complaint.
If you've been writing Rust for a while, you probably do this a dozen times a day without thinking. But the reason it works is not obvious, and understanding it unlocks a lot of other behavior that would otherwise feel like magic.
What deref coercion is
The rule is straightforward. When the compiler sees a &T in a position
where a &U is expected, and T implements
Deref<Target = U>, it automatically inserts a call to
deref() to convert &T into &U. No syntax
required from you. The compiler just does it.
The Deref trait itself is simple:
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
String implements Deref<Target = str>. So when the compiler
sees &String where &str is expected, it calls
String::deref() to get a &str. That is the entire trick
behind the greet(&s) call above.
But deref coercion does not stop at one level. The compiler will chain multiple
deref() calls if needed. Consider this:
fn greet(name: &str) {
println!("Hello, {name}!");
}
let boxed: Box<String> = Box::new(String::from("Bob"));
greet(&boxed); // works!
Here the compiler sees &Box<String> where &str
is expected. It walks the deref chain:
// The compiler does this automatically:
// &Box<String>
// -> Box<String>::deref() returns &String
// -> String::deref() returns &str
// Found the target type. Done.
Three levels deep -- &Box<String> to &String to
&str -- and you didn't write a single conversion. This chaining is what
makes smart pointers feel invisible.
Key Deref implementations in the standard library
These are the ones you'll rely on every day:
// String -> str
impl Deref for String { type Target = str; }
// Vec<T> -> [T]
impl<T> Deref for Vec<T> { type Target = [T]; }
// Box<T> -> T
impl<T> Deref for Box<T> { type Target = T; }
// Rc<T> -> T
impl<T> Deref for Rc<T> { type Target = T; }
// Arc<T> -> T
impl<T> Deref for Arc<T> { type Target = T; }
// MutexGuard<T> -> T
impl<T> Deref for MutexGuard<T> { type Target = T; }
// RwLockReadGuard<T> -> T
impl<T> Deref for RwLockReadGuard<T> { type Target = T; }
Every one of these means the same thing: "a reference to me can be used as a reference to my inner type." That single idea is what makes smart pointers in Rust feel nothing like smart pointers in C++.
Why smart pointers work so cleanly
Look at this code:
use std::sync::Arc;
let data: Arc<Vec<i32>> = Arc::new(vec![1, 2, 3, 4, 5]);
let length = data.len(); // Vec::len()
let first = data[0]; // slice indexing
let slice = &data[1..3]; // slice range
let total: i32 = data.iter().sum(); // slice iteration
data is an Arc<Vec<i32>>. It is not a
Vec. It is not a slice. But you can call .len(),
index with [0], take sub-slices, and iterate -- all methods that belong
to Vec or [i32]. The deref chain is doing all the work:
// data.len():
// Arc<Vec<i32>> --deref--> Vec<i32> --deref--> [i32]
// [i32] has .len(), so that's the one that gets called.
This is why you almost never need to write (*arc).len() or
(**arc).len(). The compiler follows the chain for you. The wrapper types
become transparent, and you interact with the data inside them as if the wrappers
weren't there.
The same applies to Rc, Box, and lock guards:
use std::sync::Mutex;
let mutex = Mutex::new(String::from("hello"));
let guard = mutex.lock().unwrap();
// guard is a MutexGuard<String>, but you use it like a &String
let len = guard.len(); // String::len()
let upper = guard.to_uppercase(); // str::to_uppercase()
let bytes = guard.as_bytes(); // str::as_bytes()
You locked a mutex and got a guard. Through deref coercion, that guard behaves like
the String inside it. The lock machinery is invisible at the point
of use.
DerefMut
Everything above is about shared references -- &T to &U.
But there is a mutable counterpart: DerefMut.
pub trait DerefMut: Deref {
fn deref_mut(&mut self) -> &mut Self::Target;
}
When the compiler sees a &mut T where a &mut U is
expected, and T: DerefMut<Target = U>, it inserts
deref_mut(). The important thing to notice is that DerefMut
requires Deref -- you must implement both, and the target type must be
the same.
Here is where this matters in practice:
use std::sync::Mutex;
let mutex = Mutex::new(vec![1, 2, 3]);
let mut guard = mutex.lock().unwrap();
// guard is a MutexGuard<Vec<i32>>
// MutexGuard implements DerefMut, so &mut guard becomes &mut Vec<i32>
guard.push(4);
guard.push(5);
guard.sort();
println!("{:?}", *guard); // [1, 2, 3, 4, 5]
push and sort take &mut self on
Vec. You are calling them on a MutexGuard. The compiler
sees &mut MutexGuard<Vec<i32>>, applies
deref_mut() to get &mut Vec<i32>, and resolves
the method. This is why you can mutate data through a lock guard without any explicit
dereferencing.
A subtlety: &mut T can also coerce to &U (not just
&mut U). A mutable reference can always be used where a shared
reference is expected. So you can mix read and write operations freely:
let mut v = vec![3, 1, 2];
let len = v.len(); // &self method (Deref to &[i32])
v.sort(); // &mut self method (DerefMut to &mut [i32])
let first = v[0]; // &self method again
Method resolution and auto-deref
Deref coercion does not only happen at function call boundaries. It is also deeply
integrated into method resolution. When you write x.method(), the compiler
goes through a specific sequence to figure out what method means:
First, it tries to call method on the type of x directly.
If that fails, it dereferences x one level (using Deref)
and tries again. It keeps derefing until it either finds the method or runs out of
Deref implementations. As a final step, it also tries unsized coercions
(like [T; N] to [T]).
This is why String appears to have hundreds of methods when it really
doesn't. String itself has a handful of methods for mutation and allocation.
Most of what you call on a String actually lives on str:
let s = String::from("Hello, World");
// These are all str methods, called through auto-deref:
let lower = s.to_lowercase(); // str::to_lowercase
let trimmed = s.trim(); // str::trim
let starts = s.starts_with('H'); // str::starts_with
let bytes = s.as_bytes(); // str::as_bytes
let found = s.contains("World"); // str::contains
The compiler tries String::to_lowercase -- doesn't exist. Then it derefs
to str and tries str::to_lowercase -- found it. All of this
happens at compile time with zero runtime cost. There is no dynamic dispatch and no
indirection in the generated code.
The same thing applies through multiple layers:
let boxed_string: Box<String> = Box::new(String::from("hello"));
// The compiler tries:
// 1. Box<String>::trim() -- nope
// 2. String::trim() -- nope
// 3. str::trim() -- found it!
let trimmed = boxed_string.trim();
This layered resolution is also why Vec<T> appears to have all
the methods of slices. When you call v.iter() or v.contains(),
those are [T] methods found through auto-deref from Vec<T>.
let v = vec![10, 20, 30];
// All slice methods, resolved through Vec's Deref to [T]:
let has_twenty = v.contains(&20); // [T]::contains
let chunks = v.chunks(2); // [T]::chunks
let window = v.windows(2); // [T]::windows
let first = v.first(); // [T]::first
When deref coercion doesn't happen
Deref coercion is not a universal type converter. There are specific situations where people expect it to kick in and it doesn't. Knowing the limits saves you from confusing compiler errors.
It only works on references
Deref coercion converts &T to &U. It does not convert
T to U. If a function wants an owned String, you
cannot pass an owned Box<String> and expect coercion to unbox it:
fn takes_string(s: String) { /* ... */ }
let boxed = Box::new(String::from("hello"));
// takes_string(boxed); // ERROR: expected String, found Box<String>
takes_string(*boxed); // OK: explicit dereference with *
The * operator explicitly moves the value out of the box. Deref coercion
only operates at the reference level.
It's one direction only
String derefs to str, so &String coerces to
&str. But str does not deref to String. If a
function wants a &String, you cannot pass a &str:
fn takes_string_ref(s: &String) { /* ... */ }
let slice: &str = "hello";
// takes_string_ref(slice); // ERROR: expected &String, found &str
This is also why the advice is to accept &str in function parameters
rather than &String. A &str parameter works with both
string slices and String values (through deref coercion), while a
&String parameter only works with String.
It doesn't work in generic contexts
This is the one that trips people up most. Deref coercion does not apply when the compiler is resolving generic type parameters:
use std::collections::HashSet;
let set: HashSet<String> = HashSet::new();
let key: &str = "hello";
// set.contains(key); // ERROR in older editions
// The contains method is generic, and type inference
// doesn't automatically apply deref coercion.
The standard library works around this for HashSet and HashMap
by using the Borrow trait in their lookup methods. That's why
set.contains("hello") works -- it uses Borrow, not
Deref. But when you write your own generic code, don't assume deref
coercion will bridge type gaps for you.
It doesn't apply to trait bounds
If a function takes impl AsRef<str>, deref coercion won't help
resolve that. The compiler checks trait implementations directly, not through
deref chains:
fn needs_display(item: &impl Display) {
println!("{item}");
}
let boxed: Box<i32> = Box::new(42);
needs_display(&boxed); // Works, but only because Box<T> implements Display
// when T: Display -- not because of deref coercion.
The distinction matters. In this case it works, but not for the reason you might
think. If Box<T> didn't have its own Display impl,
deref coercion alone would not save you.
Implementing Deref for your own types
You can implement Deref on your own types. The classic use case is the
newtype pattern, where you wrap an existing type to give it a different identity:
use std::ops::Deref;
struct Username(String);
impl Deref for Username {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
let user = Username("alice".to_string());
// Now Username gets all &str methods for free:
let len = user.len();
let upper = user.to_uppercase();
let starts = user.starts_with('a');
This looks convenient, but there is an important design guideline here.
Deref was designed for smart pointer types -- types whose whole purpose
is to wrap and provide access to another value. When you implement Deref,
you are telling the compiler: "treat my type as if it were the target type in most
contexts."
That is exactly right for Box, Rc, Arc, and
MutexGuard. Their job is to be transparent wrappers.
But for a newtype like Username, think carefully. If the whole point of
the newtype is to restrict the API -- to prevent callers from treating a username as
an arbitrary string -- then implementing Deref defeats the purpose. You
just gave back every str method, including ones that don't make sense for
a username.
struct Username(String);
// If Username implements Deref<Target = str>, then this works:
let user = Username("alice".to_string());
let weird = user.replace("a", "@"); // probably not what you want
let split = user.split_whitespace(); // why would a username have whitespace?
The guideline: implement Deref when your type is a transparent wrapper
and users should have full access to the inner type's methods. Don't implement it
when your type exists to restrict or specialize the inner type's API. In those cases,
expose only the methods that make sense by writing explicit delegation methods:
struct Username(String);
impl Username {
fn as_str(&self) -> &str {
&self.0
}
fn len(&self) -> usize {
self.0.len()
}
}
// Callers get only the methods you chose to expose.
let user = Username("alice".to_string());
let len = user.len(); // OK
let s = user.as_str(); // OK
// user.split_whitespace(); // ERROR: no such method -- good!
A good test: would you be comfortable if every single method on the target type were
callable on your type? If yes, implement Deref. If any of those methods
would be surprising or wrong, keep your type opaque and delegate manually.
Deref coercion is one of the few places where Rust inserts implicit behavior, and it
does so for a good reason. Without it, every smart pointer would require manual
dereferencing, every &String would need an explicit
.as_str(), and method calls through wrappers would be noisy and tedious.
The mechanism is simple -- follow the Deref chain until the types match --
but its effect on ergonomics is enormous. Once you see it, you'll notice it everywhere:
every .len() on a Vec, every method call through a lock guard,
every function that accepts &str and gets a &String.
It's the invisible machinery that makes Rust's type system strict without being
punishing.
Telex