Skip to Content
Course content

218: The Newtype Pattern

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to show you a bug that I've seen sneak into production more times than I'd care to admit. It's not a crash or a memory leak; it's a logic error that the compiler completely ignores because, as far as Rust is concerned, the code is perfectly valid.

struct Order {
    id: u32,
    customer_id: u32,
}

fn ship_order(order_id: u32, customer_id: u32) {
    println!("Shipping order {} to customer {}", order_id, customer_id);
}

fn main() {
    let order = Order { id: 101, customer_id: 5005 };
    
    // Oops. I swapped the arguments.
    ship_order(order.customer_id, order.id);
}

The Primitive Obsession Trap

In the example above, the code compiles without a single warning. Why? Because both the order ID and the customer ID are u32. To the compiler, they are identical. I've called this "Primitive Obsession"—relying on basic types to represent distinct domain concepts.

In a real system, this is a silent killer. You might accidentally pass a user_id into a function expecting a product_id, and instead of a compiler error, you get a runtime bug where a user is accidentally charged for the wrong item or, worse, sees someone else's private data.

Wrapping Primitives for Type Safety

We can fix this using the Newtype pattern. This involves creating a tuple struct with a single field. It sounds like a lot of boilerplate for something so simple, but it tells the compiler that these two u32 values are fundamentally different things.

struct OrderId(u32);
struct CustomerId(u32);

struct Order {
    id: OrderId,
    customer_id: CustomerId,
}

fn ship_order(order_id: OrderId, customer_id: CustomerId) {
    println!("Shipping order {:?} to customer {:?}", order_id.0, customer_id.0);
}

fn main() {
    let order = Order { 
        id: OrderId(101), 
        customer_id: CustomerId(5005) 
    };
    
    // Now, if I try to swap them:
    // ship_order(order.customer_id, order.id); 
    // ^-- The compiler now screams: "mismatched types"
    
    ship_order(order.id, order.customer_id);
}

By wrapping the primitives, we've moved the error from runtime (where the customer gets the wrong package) to compile-time (where the developer gets a red squiggly line). I personally find this incredibly satisfying because it removes the mental overhead of double-checking argument orders in every function call.

Bypassing the Orphan Rule

There's another reason I use Newtypes: the Orphan Rule. In Rust, you can't implement an external trait for an external type. For example, you can't implement Display for Vec<T> because you didn't define the trait and you didn't define the Vec.

But if you wrap that Vec in a Newtype, it becomes your type. You now own it, which means you can implement any trait you want on it.

use std::fmt;

struct MyList(Vec<String>);

impl fmt::Display for MyList {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "My list contains {} items", self.0.len())
    }
}

It's a lightweight wrapper that gives you full control over the type's behavior without the overhead of allocating a whole new data structure.




📋 Practical Task

Implementing a Type-Safe Currency Converter

You are building a financial tool that handles multiple currencies. Currently, the project uses f64 for everything, which is leading to bugs where developers are adding USD values to EUR values without converting them first.

Your Task:

  • Create two Newtypes: Usd and Eur, both wrapping an f64.
  • Create a function convert_usd_to_eur(amount: Usd) -> Eur (assume an exchange rate of 0.92).
  • Create a function add_eur(a: Eur, b: Eur) -> Eur that sums two Euro amounts.
  • In main, attempt to pass a Usd value into add_eur and observe the compiler error. Then, fix it by using your conversion function first.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.