Logo
Logo

Atharva Pandey/Lesson 10: Conversion Traits — From, Into, TryFrom, AsRef

Created Tue, 08 Oct 2024 15:30:00 +0000 Modified Tue, 08 Oct 2024 15:30:00 +0000

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:

ScenarioTraitExample
Lossless transformation, creates new valueFromString::from("hello")
Conversion that might failTryFromu8::try_from(256u32)
Cheap borrow as different typeAsRefString::as_ref() -> &str
Cheap borrow with hash/eq guaranteeBorrowString borrows as &str in HashMap
Smart pointer / newtype dereferencingDerefBox<T> derefs to &T

And some guidelines:

  • Always implement From, never IntoInto comes free.
  • Implement TryFrom when there’s any chance of failure — don’t panic on bad input.
  • Use AsRef<T> in function parameters — it makes your API flexible.
  • Implement From for 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.