Telex logo Telex

Rust Deep Dives #3: AsRef, Borrow, and ToOwned: The Three Traits Nobody Explains Well

Post 3 of 8 in Rust Deep Dives. Companion series: Rust Patterns That Matter.

Previous: #2: Deref Coercion | Next: #4: String Types

Rust has three traits that all seem to do the same thing: convert between owned and borrowed types. AsRef<T> gives you a &T. Borrow<T> gives you a &T. ToOwned goes the other direction, giving you an owned value from a borrowed one. If you've ever stared at all three and wondered why they all exist, you're not alone. The difference isn't about what they do. It's about what they promise.

The confusion

Here's what makes this confusing. Look at these two trait definitions side by side:

// AsRef
pub trait AsRef<T: ?Sized> {
    fn as_ref(&self) -> &T;
}

// Borrow
pub trait Borrow<T: ?Sized> {
    fn borrow(&self) -> &T;
}

Identical signatures. Both take &self and return &T. The compiler doesn't enforce any difference between them. The difference is a semantic contract -- a promise you make to other code that uses your type. AsRef promises very little. Borrow promises a lot. And ToOwned is the inverse of Borrow, going from borrowed back to owned. Let's take them one at a time.

AsRef<T> -- "I can give you a &T cheaply"

AsRef is the weakest promise of the three. It says: "I can hand you a reference to a T, and it won't cost anything significant." That's it. No guarantees about how the borrowed form behaves relative to the owned form. No promises about hashing, ordering, or equality. Just a cheap conversion.

Where you actually use this is in function parameters. When you write a function that accepts a file path, you could write it like this:

use std::fs;
use std::path::Path;

fn read_config(path: &Path) -> String {
    fs::read_to_string(path).unwrap()
}

That works, but it forces callers to construct a &Path explicitly. Someone with a String has to write Path::new(&my_string). Someone with a &str has to do the same. The friction is small, but it adds up across an API. Here's the AsRef version:

fn read_config(path: impl AsRef<Path>) -> String {
    fs::read_to_string(path.as_ref()).unwrap()
}

// All of these work now:
read_config("config.toml");                        // &str
read_config(String::from("config.toml"));          // String
read_config(Path::new("config.toml"));             // &Path
read_config(PathBuf::from("config.toml"));         // PathBuf
read_config(OsString::from("config.toml"));        // OsString

This is exactly what the standard library does. Look at the signature of fs::read_to_string itself:

pub fn read_to_string<P: AsRef<Path>>(path: P) -> Result<String>

The same pattern works for anything that can be viewed as a byte slice. If your function processes bytes, accept impl AsRef<[u8]> and callers can pass &[u8], Vec<u8>, &str, or String:

fn compute_hash(data: impl AsRef<[u8]>) -> u64 {
    let bytes = data.as_ref();
    let mut hash = 0u64;
    for &b in bytes {
        hash = hash.wrapping_mul(31).wrapping_add(b as u64);
    }
    hash
}

compute_hash("hello");                  // &str implements AsRef<[u8]>
compute_hash(vec![1, 2, 3]);           // Vec<u8> implements AsRef<[u8]>
compute_hash(&[0xFF, 0xFE]);          // &[u8] implements AsRef<[u8]>

You can also implement AsRef on your own types to make them work smoothly with these kinds of generic functions:

struct ConfigPath {
    inner: PathBuf,
}

impl AsRef<Path> for ConfigPath {
    fn as_ref(&self) -> &Path {
        &self.inner
    }
}

// Now ConfigPath works with any function that accepts impl AsRef<Path>
let cfg = ConfigPath { inner: PathBuf::from("/etc/app.toml") };
let contents = fs::read_to_string(&cfg).unwrap();

The key thing about AsRef: it's about API ergonomics, not about behavioral contracts. You're telling the compiler "this type can cheaply produce a reference to that type," and nothing more.

Borrow<T> -- "I can give you a &T, and it behaves the same"

Borrow looks identical to AsRef at the type level, but it carries a much stronger promise: the borrowed form must behave identically to the owned form with respect to hashing, equality, and ordering. If x == y, then x.borrow() == y.borrow(). If hash(x) == hash(y), then hash(x.borrow()) == hash(y.borrow()). Same for Ord.

This isn't enforced by the compiler. It's a contract. If you violate it, your code compiles but breaks in subtle, maddening ways. And the place where this contract matters most is HashMap and HashSet.

Look at the signature of HashMap::get:

impl<K, V> HashMap<K, V> {
    pub fn get<Q>(&self, k: &Q) -> Option<&V>
    where
        K: Borrow<Q>,
        Q: Hash + Eq,
    { ... }
}

This says: "Give me anything that the key type can borrow as." Because String implements Borrow<str>, you can look up a HashMap<String, V> with a &str -- no allocation needed:

use std::collections::HashMap;

let mut users: HashMap<String, u32> = HashMap::new();
users.insert("alice".to_string(), 42);

// Look up with &str, even though keys are String
let age = users.get("alice"); // Some(&42)

This only works because String's Borrow<str> implementation guarantees that hashing a String and hashing the corresponding &str produce the same result. If they didn't, the HashMap would hash the key at insertion time using String's hash, then hash the lookup key using str's hash, get a different bucket, and never find the entry. Everything compiles. Nothing works.

Here's why AsRef would be wrong for this. Suppose you have a case-insensitive string wrapper:

use std::hash::{Hash, Hasher};

struct CiString(String);

impl PartialEq for CiString {
    fn eq(&self, other: &Self) -> bool {
        self.0.to_lowercase() == other.0.to_lowercase()
    }
}

impl Hash for CiString {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.0.to_lowercase().hash(state);
    }
}

This type could safely implement AsRef<str> -- it can cheaply give you a &str. But it must not implement Borrow<str>, because the borrowed &str would hash differently than the CiString itself. The CiString hashes case-insensitively, but plain str hashes case-sensitively. Implementing Borrow<str> here would silently break any HashMap<CiString, V>.

This is the core distinction: AsRef says "I can produce a reference." Borrow says "I can produce a reference that is interchangeable for comparison purposes." The second is a much stronger claim.

When to use Borrow in your own functions

If you're writing a generic function that needs to compare or hash a borrowed form against an owned form, use Borrow. The classic case is lookup functions on collection-like types:

use std::borrow::Borrow;

struct Registry<K, V> {
    entries: Vec<(K, V)>,
}

impl<K: Eq, V> Registry<K, V> {
    fn find<Q>(&self, key: &Q) -> Option<&V>
    where
        K: Borrow<Q>,
        Q: Eq + ?Sized,
    {
        self.entries.iter()
            .find(|(k, _)| k.borrow() == key)
            .map(|(_, v)| v)
    }
}

let mut reg = Registry { entries: vec![] };
reg.entries.push(("timeout".to_string(), 30));

// Look up with &str, keys are String
let val = reg.find("timeout"); // Some(&30)

ToOwned -- "I can make an owned version of this borrowed data"

ToOwned goes in the opposite direction: from borrowed to owned. You might think Clone already does this, and it does -- for types where the owned and borrowed forms are the same type. Clone turns a &String into a String. But what about turning a &str into a String? Or a &[T] into a Vec<T>? The borrowed type and the owned type are different types entirely. That's what ToOwned is for.

pub trait ToOwned {
    type Owned: Borrow<Self>;

    fn to_owned(&self) -> Self::Owned;
}

Notice the bound: Owned: Borrow<Self>. This ties ToOwned and Borrow together. If str says its owned form is String, then String must implement Borrow<str>. The two traits form a round-trip: you can go from owned to borrowed (via Borrow) and from borrowed to owned (via ToOwned).

The standard library implements ToOwned for the common borrowed-to-owned pairs:

// str -> String
let s: String = "hello".to_owned();

// [T] -> Vec<T>
let v: Vec<i32> = [1, 2, 3][..].to_owned();

// Path -> PathBuf
use std::path::Path;
let p: PathBuf = Path::new("/tmp").to_owned();

// OsStr -> OsString
use std::ffi::OsStr;
let o: OsString = OsStr::new("foo").to_owned();

// For types where owned == borrowed, ToOwned just calls clone:
let n: i32 = 42.to_owned(); // same as 42.clone()

The Cow connection

ToOwned is the trait that makes Cow possible. Look at the definition of Cow:

enum Cow<'a, B: ?Sized + ToOwned> {
    Borrowed(&'a B),
    Owned(<B as ToOwned>::Owned),
}

Cow needs ToOwned so it knows how to go from the Borrowed variant to the Owned variant when you call .to_mut() or .into_owned(). Without ToOwned, Cow wouldn't know that the owned form of str is String, or that the owned form of [u8] is Vec<u8>.

This means if you implement ToOwned for your own borrowed type, your type works with Cow automatically:

use std::borrow::{Borrow, Cow};

// A borrowed configuration key (like &str for strings)
struct ConfigKey(str);

// Its owned counterpart (like String for &str)
struct OwnedConfigKey(String);

impl Borrow<ConfigKey> for OwnedConfigKey {
    fn borrow(&self) -> &ConfigKey {
        // Safety: ConfigKey is a transparent wrapper around str
        unsafe { &*(self.0.as_str() as *const str as *const ConfigKey) }
    }
}

impl ToOwned for ConfigKey {
    type Owned = OwnedConfigKey;

    fn to_owned(&self) -> OwnedConfigKey {
        OwnedConfigKey(self.0.to_owned())
    }
}

// Now Cow<ConfigKey> just works

In practice, most people don't need to implement ToOwned themselves. The standard library covers the common cases. But understanding that Cow depends on ToOwned which depends on Borrow helps explain why all three traits exist and how they fit together.

The decision framework

When you're writing a function and trying to decide which trait to use in your signature, here's how to think about it.

Writing function parameters

If you want to accept many input types and you're just going to read the data, use AsRef. This is the most common case. You have a function that works with &str or &Path or &[u8] internally, and you want callers to pass whatever they have without converting first:

// Good: accepts &str, String, &String, Cow<str>, etc.
fn log_message(msg: impl AsRef<str>) {
    let msg = msg.as_ref();
    println!("[LOG] {msg}");
}

// Good: accepts anything path-like
fn ensure_dir(path: impl AsRef<Path>) -> std::io::Result<()> {
    fs::create_dir_all(path.as_ref())
}

If you need to compare, hash, or look up borrowed keys against owned keys, use Borrow. This is primarily for collection-like APIs:

use std::borrow::Borrow;
use std::collections::HashSet;

fn contains_any<T, Q>(set: &HashSet<T>, candidates: &[Q]) -> bool
where
    T: Hash + Eq + Borrow<Q>,
    Q: Hash + Eq + ?Sized,
{
    candidates.iter().any(|c| set.contains(c))
}

let tags: HashSet<String> = ["rust", "async", "tui"]
    .iter().map(|s| s.to_string()).collect();

// Pass &str slices even though set contains Strings
let found = contains_any(&tags, &["rust", "python"]); // true

If you need to go from borrowed to owned -- for instance, you're cloning data out of a borrowed reference into a collection you own -- use ToOwned:

use std::borrow::ToOwned;

fn collect_unique_words(text: &str) -> Vec<String> {
    let mut seen = HashSet::new();
    let mut result = Vec::new();

    for word in text.split_whitespace() {
        if seen.insert(word) {
            result.push(word.to_owned()); // &str -> String
        }
    }
    result
}

Implementing on your own types

If your type wraps another type and you want it to work with generic APIs that accept impl AsRef<T>, implement AsRef. This is common for newtype wrappers:

struct UserId(String);

impl AsRef<str> for UserId {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

// Now UserId works anywhere that accepts impl AsRef<str>
log_message(&UserId("user_42".to_string()));

Only implement Borrow if your type's borrowed form preserves equality, hashing, and ordering. For a simple newtype around String where you haven't changed any of those behaviors, implementing Borrow<str> is fine. But if you've overridden Hash or PartialEq to behave differently from the inner type, stick with AsRef.

The complete picture

Let's put all three together in a realistic scenario. Imagine a simple in-memory cache that maps string keys to values. We want the insert API to be ergonomic (accept various string types), lookups to work with &str, and we want to be able to clone keys out of the cache when needed.

use std::borrow::Borrow;
use std::collections::HashMap;

struct Cache<V> {
    data: HashMap<String, V>,
}

impl<V> Cache<V> {
    fn new() -> Self {
        Cache { data: HashMap::new() }
    }

    // AsRef: accept any string-like type for insertion.
    // We need to own the key, so we convert to String internally.
    fn insert(&mut self, key: impl AsRef<str>, value: V) {
        self.data.insert(key.as_ref().to_owned(), value);
    }

    // Borrow: look up with &str even though keys are String.
    // This works because String: Borrow<str> guarantees
    // consistent hashing between String and &str.
    fn get(&self, key: &str) -> Option<&V> {
        self.data.get(key)
    }

    // ToOwned: clone keys out of the cache.
    // We iterate over borrowed &str keys and produce owned Strings.
    fn keys(&self) -> Vec<String> {
        self.data.keys().map(|k| k.as_str().to_owned()).collect()
    }
}

And here's how it looks at the call site:

let mut cache = Cache::new();

// insert accepts &str, String, Cow<str>, anything AsRef<str>
cache.insert("timeout", 30);
cache.insert(String::from("retries"), 3);
cache.insert(format!("port_{}", 0), 8080);

// get uses Borrow under the hood -- look up with &str
if let Some(&timeout) = cache.get("timeout") {
    println!("Timeout is {timeout} seconds");
}

// keys uses to_owned to produce owned Strings from borrowed data
let all_keys: Vec<String> = cache.keys();
println!("Cache contains: {all_keys:?}");

Notice how each trait serves a different role. AsRef makes the insertion API ergonomic -- callers don't have to pre-convert their keys. The HashMap internally relies on Borrow to let us look up String keys with &str values, trusting that the hashes match. ToOwned (via .to_owned()) lets us clone borrowed data into owned values when we need to hand them out.

The three traits aren't redundant. They form a layered system of promises. AsRef promises a cheap reference with no behavioral guarantees. Borrow promises a cheap reference that behaves identically for hashing, equality, and ordering. ToOwned promises the reverse direction -- turning a borrowed value into an owned one -- and ties back to Borrow through its associated type bound. Together, they give Rust a way to express the full owned-borrowed relationship at the type level, with exactly the right constraints for each use case.