Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 090 / 100

Rust traits for conversion and display, explained simply

Display, Debug and AsRef.

Expona Admin8 min read

Introduction

By day 90 of your Rust journey, you've built data structures, handled errors, and worked with lifetimes. But converting and displaying data elegantly separates rough code from production code. Rust traits for conversion and display, explained simply, help you write APIs that feel natural to use and output that developers actually understand.

In this article, we'll explore three core traits that make this possible: Display for user-facing output, Debug for developer output, and AsRef for flexible parameters. We'll also discuss when to reach for Deref and why restraint matters.

Display for user-facing output

Display is your contract for presenting data to end users. When someone calls your API or views application output, they see the result of a Display implementation. It must be human-readable, context-aware, and never panic.

Let's say we're building an e-commerce system with order statuses. A user shouldn't see OrderStatus::PendingPaymentProcessing; they should see "Waiting for payment confirmation".

rust
use std::fmt;

#[derive(Clone, Copy, Debug)]
enum OrderStatus {
    PendingPaymentProcessing,
    Shipped,
    Delivered,
    Cancelled,
}

impl fmt::Display for OrderStatus {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let text = match self {
            OrderStatus::PendingPaymentProcessing => "Waiting for payment confirmation",
            OrderStatus::Shipped => "On its way to you",
            OrderStatus::Delivered => "Delivered",
            OrderStatus::Cancelled => "Order cancelled",
        };
        write!(f, "{}", text)
    }
}

fn main() {
    let status = OrderStatus::PendingPaymentProcessing;
    println!("{}", status);  // Output: "Waiting for payment confirmation"
}

The Display trait requires implementing a single method: fmt(&self, f: &mut Formatter). You write to the formatter using the write! macro, which is safe and never panics on formatting errors.

A more complex example: a price formatter that respects locale.

rust
use std::fmt;

struct Price {
    amount: f64,
    currency: &'static str,
}

impl fmt::Display for Price {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{} {:.2}", self.currency, self.amount)
    }
}

fn main() {
    let price = Price { amount: 29.99, currency: "USD" };
    println!("Price: {}", price);  // Output: "Price: USD 29.99"
    
    let price_eur = Price { amount: 27.50, currency: "EUR" };
    println!("Price: {}", price_eur);  // Output: "Price: EUR 27.50"
}

Display should be deterministic and stable. Don't include debug information or internal state that users don't care about. If you need to show prices in different formats or currencies, create separate functions; Display should pick a sensible default.

Debug for developer output

Debug is for you and your team. It's what you print when something goes wrong, when you're testing, or when you need to understand internal state. Unlike Display, Debug can be verbose and include implementation details.

Most of the time, you'll derive Debug:

rust
#[derive(Debug)]
struct User {
    id: u64,
    email: String,
    active: bool,
}

fn main() {
    let user = User {
        id: 123,
        email: "alice@example.com".to_string(),
        active: true,
    };
    println!("{:?}", user);
    // Output: User { id: 123, email: "alice@example.com", active: true }
}

The derived Debug is usually sufficient. But when you have sensitive data, custom types, or complex relationships, implement it explicitly.

rust
use std::fmt;

struct ApiKey {
    value: String,
}

impl fmt::Debug for ApiKey {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.debug_struct("ApiKey")
            .field("value", &"***redacted***")
            .finish()
    }
}

fn main() {
    let key = ApiKey { value: "super-secret-12345".to_string() };
    println!("{:?}", key);  // Output: ApiKey { value: "***redacted***" }
}

This prevents accidental credential leaks in logs. For collections and custom types with nested data, use the builder methods on Formatter: debug_struct, debug_tuple, debug_list, and debug_map.

Never implement Debug to panic or fail. If formatting is truly impossible, return an error from the formatter:

rust
use std::fmt;

struct LargeBuffer {
    data: Vec<u8>,
}

impl fmt::Debug for LargeBuffer {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.debug_struct("LargeBuffer")
            .field("size_bytes", &self.data.len())
            .finish()
    }
}

fn main() {
    let buf = LargeBuffer { data: vec![0u8; 1_000_000] };
    println!("{:?}", buf);  // Output: LargeBuffer { size_bytes: 1000000 }
}

AsRef for flexible parameters

AsRef lets functions accept multiple types without cloning. It's a zero-cost abstraction for reference conversion. When you write a function that works with string data, you want to accept &str, &String, and ideally other types that can be converted to &str.

rust
fn send_email<S: AsRef<str>>(recipient: S, subject: S, body: S) {
    let recipient = recipient.as_ref();
    let subject = subject.as_ref();
    let body = body.as_ref();
    println!("Email to {}: {} - {}", recipient, subject, body);
}

fn main() {
    let email = String::from("user@example.com");
    send_email(&email, "Hello", "Welcome aboard");
    send_email("user@example.com", "Hello", "Welcome aboard");
    
    let mut s = String::from("Direct string");
    send_email(&s, "Test", "Message");
}

This pattern is everywhere in the standard library. std::fs::File::open accepts anything implementing AsRef<Path>, so you can pass &str, &Path, or PathBuf directly.

For paths:

rust
use std::fs::File;
use std::path::Path;

fn read_config<P: AsRef<Path>>(path: P) -> std::io::Result<String> {
    let path = path.as_ref();
    std::fs::read_to_string(path)
}

fn main() {
    let _ = read_config("config.toml");
    let _ = read_config("config.toml".to_string());
    let _ = read_config(std::path::PathBuf::from("config.toml"));
}

AsRef is ideal when you own the parameters. It doesn't require ownership transfer, and callers don't need to pre-convert their data. Implement AsRef for your types if they naturally wrap or contain another type:

rust
struct UserId(u64);

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

fn log_user<T: AsRef<u64>>(user: T) {
    println!("User ID: {}", user.as_ref());
}

fn main() {
    let id = UserId(42);
    log_user(id);
    log_user(42u64);
}

Deref sparingly

Deref is powerful but dangerous. It enables automatic dereferencing, which makes code feel magical until it silently does the wrong thing. Use it only when you're certain about the type relationship and user expectations.

The common pattern is implementing Deref on a wrapper type to provide transparent access to its inner type:

rust
use std::ops::Deref;

struct UserId(u64);

impl Deref for UserId {
    type Target = u64;
    fn deref(&self) -> &u64 {
        &self.0
    }
}

fn main() {
    let id = UserId(42);
    println!("{}", *id);  // Explicit deref
    println!("{}", id.checked_add(1));  // Implicit deref (u64 methods available)
}

This feels convenient at first. But it creates a problem: you're hiding an abstraction boundary. Code using your UserId type starts treating it like a bare u64, losing type safety. If you later need to add validation or change the internal representation, everything breaks.

A safer approach is to provide explicit methods:

rust
struct UserId(u64);

impl UserId {
    fn value(&self) -> u64 {
        self.0
    }
    
    fn to_u64(self) -> u64 {
        self.0
    }
}

fn main() {
    let id = UserId(42);
    println!("{}", id.value());  // Explicit, intentional
}

Use Deref only for transparent wrappers where the inner type is truly a synonym (like Cow<str> or String), not for semantic types with meaning. Collections like Vec and String implement Deref because they're commonly used as their slice equivalents, and that pattern is well understood.

Common mistakes

Mixing Display and Debug: Beginners often use Debug for user output. Remember: Display is for end users, Debug is for developers. If you find yourself showing internal state in Display, you're likely in the wrong trait.

Over-using Deref: Implementing Deref on wrapper types feels clever but erodes type safety. Your team will spend debugging sessions wondering why UserId methods are available when they shouldn't be. Stick with explicit methods.

AsRef without bounds: If you're passing a reference around and the type doesn't implement AsRef, don't implement it just to make one function work. Your API contract should be consistent. Either always use AsRef or always use concrete types.

Panicking in Display or Debug: These implementations should be safe and fast. Never unwrap, never panic, never lock mutexes. If you can't format the value safely, skip fields or return a simplified representation.

Key takeaways

  • Display is for end users and applications. Implement it for any public type that users see in output. Keep it simple, readable, and stable.
  • Debug is for developers. Derive it when possible; implement it explicitly to hide sensitive data or simplify complex nested types.
  • AsRef makes your APIs flexible without runtime cost. Use it for function parameters that accept multiple types that logically convert to the same target.
  • Implement AsRef for your own types when they wrap or contain another type as a natural conversion.
  • Deref is powerful and should be rare. Use it only for truly transparent wrappers where the inner type is a synonym. For semantic types, provide explicit methods instead.
  • Never panic or fail in Display or Debug implementations. Keep them fast and safe.
  • Prefer explicit over implicit. Your future self and your team will appreciate clarity over magic.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles