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.
Telex