Telex logo Telex

Rust Patterns That Matter #1: Newtype

Post 1 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.

Next: #2: From / Into Conversions

Two function parameters have the same type. A caller swaps them by accident. The program compiles, runs, and produces wrong results. The type system could have caught this, but both parameters were u64 and the compiler can't tell them apart.

The motivation

fn transfer(from: u64, to: u64, amount: u64) {
    // ...
}

// Oops: swapped from and to
transfer(recipient_id, sender_id, 500);

This compiles. All three arguments are u64. The compiler has no way to know that from is a sender and to is a recipient. You might try type aliases:

type UserId = u64;
type Amount = u64;

fn transfer(from: UserId, to: UserId, amount: Amount) { /*...*/ }

But type aliases are transparent. UserId and u64 are the same type to the compiler. You can still pass an Amount where a UserId is expected. No protection.

The pattern: newtype

Wrap the inner type in a single-field tuple struct:

struct UserId(u64);
struct OrderId(u64);
struct Amount(u64);

fn transfer(from: UserId, to: UserId, amount: Amount) {
    // ...
}

fn main() {
    let sender = UserId(42);
    let recipient = UserId(99);
    let amount = Amount(500);

    transfer(sender, recipient, amount); // OK

    let order = OrderId(7);
    // transfer(order, recipient, amount);
    // ERROR: expected `UserId`, found `OrderId`
}

UserId, OrderId, and Amount are now distinct types. Passing an OrderId where a UserId is expected is a compile error. The type system catches the bug.

Zero-cost abstraction

A newtype compiles to exactly the same machine code as the inner type. There's no wrapper struct at runtime, no indirection, no overhead. UserId(42) is just 42 in memory. The distinction exists only in the type system, and the type system is erased at compile time.

The orphan rule workaround

Rust's orphan rule prevents you from implementing a foreign trait on a foreign type. You can't write impl Display for Vec<u8> in your crate - both Display and Vec are defined elsewhere.

The newtype pattern gives you a local type to attach the impl to:

use std::fmt;

struct Bytes(Vec<u8>);

impl fmt::Display for Bytes {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{} bytes", self.0.len())
    }
}

Bytes is a type in your crate, so you can implement any trait on it. Inside, it's just a Vec<u8>.

Accessing the inner value

The simplest approach: access the field directly with .0:

let id = UserId(42);
let raw: u64 = id.0;

For a more explicit API, add a method:

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

You can also implement Deref<Target = u64> to make the newtype transparent for reads. But be deliberate - Deref makes the compiler automatically coerce &UserId to &u64, which weakens the type safety you added. If you want UserId and u64 to be interchangeable for reads, implement Deref. If you want the distinction enforced everywhere, don't.

Deriving traits

Newtypes often need the same traits as their inner type. Use derive:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);

This gives you hashing (for HashMap keys), equality, debug printing, and cheap copies - everything you'd expect from a u64 wrapper.

When to use it

When not to: if you genuinely want the types to be interchangeable (a type alias is fine), or if the wrapping adds ceremony without catching real bugs.

See it in practice: Building a Chat Server #1: Hello, TCP uses this pattern for type-safe UserId and RoomId.