I used to litter my Rust code with .to_string(), as, and manual conversion functions everywhere. Then I learned the conversion traits properly and my APIs went from clunky to clean. These traits are the glue that makes Rust code feel ergonomic — and understanding when to implement each one is the difference between a library that’s pleasant to use and one that makes people curse your name.
From and Into — Infallible Conversion
From<T> defines how to create a type from another type. It can’t fail. If there’s any possibility of failure, you want TryFrom instead.
struct Celsius(f64);
struct Fahrenheit(f64);
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self {
Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
}
}
impl From<Fahrenheit> for Celsius {
fn from(f: Fahrenheit) -> Self {
Celsius((f.0 - 32.0) * 5.0 / 9.0)
}
}
fn main() {
let boiling = Celsius(100.0);
let boiling_f = Fahrenheit::from(boiling);
println!("100°C = {}°F", boiling_f.0);
let freezing = Fahrenheit(32.0);
let freezing_c = Celsius::from(freezing);
println!("32°F = {}°C", freezing_c.0);
}
Here’s the magic: implementing From<A> for B automatically gives you Into<B> on A. You never implement Into directly — always implement From.
struct Celsius(f64);
struct Fahrenheit(f64);
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self {
Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
}
}
fn print_fahrenheit(temp: impl Into<Fahrenheit>) {
let f: Fahrenheit = temp.into();
println!("Temperature: {}°F", f.0);
}
fn main() {
// Works because Celsius implements Into<Fahrenheit> (via From)
print_fahrenheit(Celsius(100.0));
// Also works with Fahrenheit directly (Into<T> for T is always implemented)
print_fahrenheit(Fahrenheit(72.0));
}
The impl Into<T> parameter pattern is incredibly common in Rust APIs. It lets callers pass either the expected type or anything that converts to it. You see this everywhere in the standard library.
From in the Standard Library
From implementations are all over the stdlib. Here are the ones I use daily:
fn main() {
// String from &str
let s: String = String::from("hello");
let s: String = "hello".into(); // Same thing via Into
// Vec<u8> from &str
let bytes: Vec<u8> = Vec::from("hello");
// String from Vec<u8> (when valid UTF-8)
// This one is actually TryFrom, shown later
// PathBuf from String
let path: std::path::PathBuf = "/usr/local".into();
// Box<str> from String
let boxed: Box<str> = String::from("hello").into();
// Various integer conversions (widening is infallible)
let x: i64 = i32::MAX.into(); // i32 -> i64, always safe
let y: f64 = 42i32.into(); // i32 -> f64, always safe
let z: u64 = 42u32.into(); // u32 -> u64, always safe
println!("{s}, {x}, {y}, {z}");
}
Implementing From for Your Types
Here’s where it gets practical. Well-placed From implementations make your APIs dramatically nicer to use.
use std::collections::HashMap;
#[derive(Debug)]
struct Config {
settings: HashMap<String, String>,
}
// From a HashMap directly
impl From<HashMap<String, String>> for Config {
fn from(settings: HashMap<String, String>) -> Self {
Config { settings }
}
}
// From a slice of tuples
impl From<&[(&str, &str)]> for Config {
fn from(pairs: &[(&str, &str)]) -> Self {
let settings = pairs.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Config { settings }
}
}
impl Config {
fn get(&self, key: &str) -> Option<&str> {
self.settings.get(key).map(|s| s.as_str())
}
}
fn main() {
// From slice of tuples
let config = Config::from(&[
("host", "localhost"),
("port", "8080"),
("debug", "true"),
] as &[(&str, &str)]);
println!("{:?}", config);
// From HashMap
let mut map = HashMap::new();
map.insert("timeout".to_string(), "30".to_string());
let config: Config = map.into();
println!("{:?}", config);
}
From for Error Types
This is where From really shines. Implementing From<SpecificError> for MyError lets the ? operator automatically convert errors:
use std::fmt;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
Custom(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "IO error: {e}"),
AppError::Parse(e) => write!(f, "Parse error: {e}"),
AppError::Custom(msg) => write!(f, "{msg}"),
}
}
}
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::Io(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
impl From<&str> for AppError {
fn from(msg: &str) -> Self {
AppError::Custom(msg.to_string())
}
}
fn read_port_from_file(path: &str) -> Result<u16, AppError> {
let contents = std::fs::read_to_string(path)?; // io::Error -> AppError
let port: u16 = contents.trim().parse()?; // ParseIntError -> AppError
if port < 1024 {
return Err("port must be >= 1024".into()); // &str -> AppError
}
Ok(port)
}
fn main() {
match read_port_from_file("/tmp/port.txt") {
Ok(port) => println!("Port: {port}"),
Err(e) => println!("Error: {e}"),
}
}
Every ? in read_port_from_file calls From::from() to convert the specific error type into AppError. That’s three different error types handled with zero explicit conversion code at the call site.
TryFrom and TryInto — Fallible Conversion
When conversion can fail, use TryFrom. It returns Result<T, E>:
use std::num::TryFromIntError;
#[derive(Debug)]
struct Port(u16);
#[derive(Debug)]
enum PortError {
OutOfRange(String),
ParseError(std::num::ParseIntError),
}
impl std::fmt::Display for PortError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PortError::OutOfRange(msg) => write!(f, "{msg}"),
PortError::ParseError(e) => write!(f, "parse error: {e}"),
}
}
}
impl TryFrom<u32> for Port {
type Error = PortError;
fn try_from(value: u32) -> Result<Self, Self::Error> {
if value == 0 || value > 65535 {
Err(PortError::OutOfRange(format!("{value} is not a valid port")))
} else {
Ok(Port(value as u16))
}
}
}
impl TryFrom<&str> for Port {
type Error = PortError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let num: u32 = value.parse().map_err(PortError::ParseError)?;
Port::try_from(num)
}
}
fn main() {
let p1 = Port::try_from(8080u32);
let p2 = Port::try_from(70000u32);
let p3 = Port::try_from("3000");
let p4 = Port::try_from("not_a_number");
println!("8080: {p1:?}");
println!("70000: {p2:?}");
println!("\"3000\": {p3:?}");
println!("\"bad\": {p4:?}");
// TryInto works too (automatically derived from TryFrom)
let port: Result<Port, _> = 443u32.try_into();
println!("443: {port:?}");
}
TryFrom in the Standard Library
Integer narrowing is the classic stdlib use case:
fn main() {
// Widening conversions use From (can't fail)
let wide: i64 = i32::MAX.into();
// Narrowing conversions use TryFrom (can fail)
let narrow: Result<i32, _> = i64::MAX.try_into();
println!("i64::MAX as i32: {narrow:?}"); // Err
let fits: Result<i32, _> = 42i64.try_into();
println!("42i64 as i32: {fits:?}"); // Ok(42)
// Useful for safe casting
let big_number: u64 = 300;
match u8::try_from(big_number) {
Ok(n) => println!("Fits in u8: {n}"),
Err(_) => println!("{big_number} doesn't fit in u8"),
}
}
AsRef and AsMut — Cheap Borrowing
AsRef<T> says “I can give you a &T cheaply.” It’s the trait version of a reference conversion. No allocation, no computation — just a reinterpretation.
use std::path::Path;
// Accept anything that can be viewed as a path
fn file_exists(path: impl AsRef<Path>) -> bool {
path.as_ref().exists()
}
// Accept anything that can be viewed as bytes
fn byte_count(data: impl AsRef<[u8]>) -> usize {
data.as_ref().len()
}
// Accept anything that can be viewed as a string
fn word_count(text: impl AsRef<str>) -> usize {
text.as_ref().split_whitespace().count()
}
fn main() {
// file_exists works with &str, String, &Path, PathBuf
println!("{}", file_exists("Cargo.toml"));
println!("{}", file_exists(String::from("Cargo.toml")));
println!("{}", file_exists(Path::new("Cargo.toml")));
// byte_count works with &str, String, &[u8], Vec<u8>
println!("{}", byte_count("hello"));
println!("{}", byte_count(String::from("hello")));
println!("{}", byte_count(vec![1u8, 2, 3]));
println!("{}", byte_count(&[1u8, 2, 3][..]));
// word_count works with &str and String
println!("{}", word_count("hello world foo"));
println!("{}", word_count(String::from("one two three four")));
}
This is the pattern that makes Rust APIs feel flexible without sacrificing type safety. The stdlib uses AsRef<Path> extensively — every file operation accepts impl AsRef<Path>.
Implementing AsRef
#[derive(Debug)]
struct Username(String);
impl AsRef<str> for Username {
fn as_ref(&self) -> &str {
&self.0
}
}
impl AsRef<[u8]> for Username {
fn as_ref(&self) -> &[u8] {
self.0.as_bytes()
}
}
fn print_length(s: impl AsRef<str>) {
println!("Length: {}", s.as_ref().len());
}
fn main() {
let user = Username("alice".to_string());
print_length(&user); // Works because Username: AsRef<str>
// Can also be used as bytes
let bytes: &[u8] = user.as_ref();
println!("Bytes: {bytes:?}");
}
Borrow vs. AsRef — The Subtle Difference
Borrow<T> looks a lot like AsRef<T>, but it carries an extra contract: the borrowed form must have the same Hash, Eq, and Ord behavior as the owned form. This matters for HashMap lookups:
use std::collections::HashMap;
use std::borrow::Borrow;
fn main() {
let mut map: HashMap<String, i32> = HashMap::new();
map.insert("alice".to_string(), 95);
map.insert("bob".to_string(), 87);
// This works because String: Borrow<str>
// and the hash of "alice" (as &str) equals the hash of String::from("alice")
let score = map.get("alice"); // No allocation needed!
println!("Alice's score: {score:?}");
// If we used AsRef instead of Borrow, there'd be no guarantee
// that the hash values match, and lookups could silently fail
}
Rule of thumb: implement AsRef when you want to offer cheap reference conversion. Implement Borrow when you’re also promising that hashing and comparison behave identically.
Deref — The Implicit Conversion
Deref isn’t technically a conversion trait, but it acts like one through “deref coercion.” When Rust needs a &T and you give it a &U where U: Deref<Target = T>, it automatically calls deref().
use std::ops::Deref;
struct Email(String);
impl Deref for Email {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
fn main() {
let email = Email("user@example.com".to_string());
// Deref coercion: &Email -> &str
println!("Length: {}", email.len()); // str::len()
println!("Upper: {}", email.to_uppercase()); // str::to_uppercase()
println!("Contains @: {}", email.contains('@'));
// Works anywhere &str is expected
fn validate(email: &str) -> bool {
email.contains('@') && email.contains('.')
}
println!("Valid: {}", validate(&email)); // &Email coerces to &str
}
Be careful with Deref — it’s powerful but can be confusing. The convention is to only implement it for smart pointer types (like Box, Rc, Arc) and newtype wrappers. Don’t use it as a general-purpose inheritance mechanism.
The Conversion Decision Matrix
This is how I decide which trait to implement:
| Scenario | Trait | Example |
|---|---|---|
| Lossless transformation, creates new value | From | String::from("hello") |
| Conversion that might fail | TryFrom | u8::try_from(256u32) |
| Cheap borrow as different type | AsRef | String::as_ref() -> &str |
| Cheap borrow with hash/eq guarantee | Borrow | String borrows as &str in HashMap |
| Smart pointer / newtype dereferencing | Deref | Box<T> derefs to &T |
And some guidelines:
- Always implement
From, neverInto—Intocomes free. - Implement
TryFromwhen there’s any chance of failure — don’t panic on bad input. - Use
AsRef<T>in function parameters — it makes your API flexible. - Implement
Fromfor your error types — it makes?work seamlessly. - Don’t over-convert — not every type needs conversions to every other type. Only add what callers actually need.
These traits are small — most implementations are five lines or fewer. But they compound. A type with good conversion trait implementations feels native to the language. Without them, every call site is cluttered with explicit conversions that obscure the actual logic. The few minutes you spend implementing From will save hours for everyone who uses your code.