keyboard-shortcut
d

When Should You Use Rust's AsRef Trait?

7 min read

Rust's AsRef trait can look unnecessary at first. If a function needs a reference, why not give it a parameter such as &Path or &str?

Most of the time, that is exactly what you should do. AsRef becomes useful when a function should accept several input types that can all be cheaply viewed as the same borrowed type.

The short version

This function accepts only a reference to a Path:

use std::path::Path;

fn print_path(path: &Path) {
    println!("{}", path.display());
}

Callers holding a string must perform the conversion themselves:

print_path(Path::new("notes/todo.txt"));

Using AsRef<Path> moves that small piece of adaptation into the function:

use std::path::Path;

fn print_path(path: impl AsRef<Path>) {
    let path = path.as_ref();
    println!("{}", path.display());
}

print_path("notes/todo.txt");
print_path(String::from("notes/todo.txt"));
print_path(Path::new("notes/todo.txt"));

All three arguments can provide a &Path. The function does not need to know which concrete type the caller used.

That is the purpose of AsRef<T>: cheaply borrow a value as &T.

What as_ref() actually does

The important part of the trait is essentially:

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

Calling as_ref() borrows the input. It does not consume it, clone it, or allocate a new value.

let name = String::from("Ferris");
let view: &str = name.as_ref();

assert_eq!(view, "Ferris");
println!("{name}"); // `name` still belongs to us

Here, a String is viewed as a string slice. The characters remain owned by the original String.

Despite the similar spelling, AsRef is unrelated to Rust's as operator. The as operator performs certain casts, such as u8 to u32. AsRef is a trait for borrowing.

Why it is useful in function parameters

File APIs are the classic example because callers naturally have paths in several forms:

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

fn file_size(path: impl AsRef<Path>) -> io::Result<u64> {
    let metadata = fs::metadata(path.as_ref())?;
    Ok(metadata.len())
}

A caller can pass a string literal, String, &Path, or PathBuf:

use std::path::PathBuf;

let path = PathBuf::from("report.txt");
let size = file_size(&path)?;

Passing &path is worth noticing. A parameter written as impl AsRef<Path> is passed by value, so passing an owned PathBuf would move it. Passing &path lets the function receive a cheap reference instead:

let size = file_size(&path)?;
println!("still available: {}", path.display());

AsRef makes the accepted representation flexible; it does not change Rust's ownership rules.

The standard library uses this pattern in functions such as std::fs::read, whose path parameter is impl AsRef<Path>. It lets callers use the path type they already have without requiring the function to allocate or take ownership of the underlying path.

When a normal reference is better

Do not add AsRef to every function that borrows something. If callers already work with one natural type, a reference is clearer:

fn word_count(text: &str) -> usize {
    text.split_whitespace().count()
}

Both of these calls are already convenient because Rust can coerce &String to &str:

let text = String::from("hello from Rust");

assert_eq!(word_count(&text), 3);
assert_eq!(word_count("hello from Rust"), 3);

Writing fn word_count(text: impl AsRef<str>) would accept owned strings by value, but the function does not need ownership. The simpler &str signature communicates that directly.

A useful rule is:

  • Use &T when callers can naturally borrow the type you need.
  • Use impl AsRef<T> when supporting several common input representations makes the API meaningfully easier to call.

This is why AsRef<Path> is common, while AsRef<u64> is usually needless.

AsRef is not Deref

Deref powers implicit dereference coercions. It is what allows &String to be used where &str is expected. Smart pointers such as Box<T> also implement it so they behave like references to their contents.

AsRef is explicit: the function calls .as_ref() to request a particular borrowed view. A type can also implement AsRef for more than one target type, while Deref has one Target.

For ordinary application code, use the behavior rather than implementing Deref merely to make a conversion convenient. Implementing AsRef for a cheap, unsurprising borrowed view is the more focused promise.

AsRef is not Borrow

Borrow has a stronger contract. If a type implements Borrow<T>, its borrowed form must behave equivalently for Eq, Ord, and Hash. Collections rely on that promise to support lookups without constructing an owned key. For example, a HashMap<String, V> can be searched with a &str.

AsRef only promises a cheap reference conversion. Use it for general-purpose function inputs. Use Borrow when code—especially collection lookup code—needs the owned and borrowed forms to be logically equivalent.

Implementing AsRef for your own type

Suppose an application wraps a path with extra meaning:

use std::path::{Path, PathBuf};

struct ConfigPath(PathBuf);

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

ConfigPath can now be passed to functions accepting AsRef<Path>:

let config = ConfigPath(PathBuf::from("app.toml"));
let size = file_size(&config)?;

An AsRef implementation should be cheap and should only borrow from self. If producing the target requires allocation, parsing, validation, or a conversion that can fail, another API is more appropriate.

The practical takeaway

Reach for AsRef when you are designing a function and can finish this sentence:

Callers may hold several types, but my function only needs to borrow all of them as &T.

Paths are the clearest example: callers may have str, String, Path, or PathBuf, while the function only needs a &Path. For a function that simply needs a string slice or a reference to one concrete type, start with &str or &T. The ordinary reference is simpler, and you can introduce AsRef later if real callers benefit from the added flexibility.