Telex logo Telex

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.