Pass a u64 user ID to a function that expects an order ID, and Rust will not complain. Both are u64. The compiler sees identical types, so it cannot help you. You find out at runtime, usually in production, usually after a refactor you thought was safe.

This is the exact bug class newtypes exist to eliminate.

A newtype is a single-field tuple struct that wraps an existing type: struct UserId(u64);. At compile time, UserId and OrderId are incompatible. At runtime, they occupy exactly the same bytes as a bare u64. No extra allocations, no indirection, no cost.

What problem does this actually solve?

Every language with static types has this problem. You have two values with the same representation but different meanings. Database keys, physical units, currency amounts, percentages versus raw counts. In C you would use a typedef, in Go a type alias, but those are just names for the same underlying type. The compiler still treats them as identical.

Rust’s newtype pattern is different. struct UserId(u64); creates a distinct type. You cannot pass a UserId where an OrderId is expected. You cannot accidentally add a percentage to a raw count. The error surfaces at compile time, not in a production incident.

This is what people mean by “making illegal states unrepresentable.” It is not theoretical. I once saw an API endpoint that accepted an account ID and a transfer amount, both u64. A refactor swapped them in a caller. Nothing broke during compilation. Money moved to the wrong place. With newtypes, that refactor would have failed to compile before anyone deployed it.

How newtypes work under the hood

A newtype in Rust is just a struct with one unnamed field:

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

The compiler treats UserId as a completely separate type from OrderId and from u64. You construct it explicitly: let id = UserId(42);. You access the inner value with id.0.

Because the struct has a single field with a known size, the compiler applies an optimization that is not actually an optimization at all, just how structs work. The memory layout of UserId is identical to u64. Same size, same alignment, same ABI.

You can verify this yourself:

use std::mem;

assert_eq!(mem::size_of::<UserId>(), mem::size_of::<u64>());
assert_eq!(mem::align_of::<UserId>(), mem::align_of::<u64>());

There is no vtable, no discriminant, no wrapper object. The generated machine code for passing a UserId into a function is identical to passing a u64. The type system enforces the distinction. The runtime erases it.

A concrete example: mixing up pixels and points

Here is a pattern that bit me once. A graphics library had distances in both pixels and device-independent points. Both were f32. I passed points where pixels were expected, and my UI rendered at half size on high-DPI screens. The compiler was silent because both were f32.

With newtypes:

struct Pixels(f32);
struct Points(f32);

fn scale_to_pixels(points: Points, dpi: f32) -> Pixels {
    Pixels(points.0 * dpi / 96.0)
}

fn draw_line(length: Pixels) {
    // render at this pixel length
}

fn main() {
    let width = Points(150.0);
    let dpi = 192.0;

    // This compiles:
    draw_line(scale_to_pixels(width, dpi));

    // This does not:
    // draw_line(width);
    // error: expected `Pixels`, found `Points`
}

The compiler rejects the mistake before you ever run the program. The Points and Pixels values use the same four bytes as f32. The safety costs nothing at runtime.

The trade-offs nobody tells you about

Newtypes are not free to write. The wrapped type’s methods do not automatically propagate. A u64 has wrapping_add, leading_zeros, dozens of methods. A bare UserId has none of them unless you implement them yourself.

You have three options, and only one of them is good.

Option 1: Implement Deref. This gives you all the inner type’s methods through auto-deref:

use std::ops::Deref;

struct UserId(u64);

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

This works, but it defeats the purpose. Deref allows implicit coercion from UserId to u64, which means you can pass a UserId anywhere a u64 is expected. You lose the type safety you just bought. Do not do this for newtypes.

Option 2: Implement everything manually. This is tedious, but you only expose the operations that make sense for your domain. For an ID type, maybe you only need Display, Debug, PartialEq, Eq, and Hash:

use std::fmt;

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

impl fmt::Display for UserId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

For a numeric type with arithmetic, you implement Add, Sub, and so on. This is boilerplate. The standard derive macros and crates like derive_more help, but it is still more code than a raw type.

Option 3: Use the inner value directly when you need it. This is my preference. Keep the newtype at API boundaries, unwrap with .0 when you need the raw value, and avoid Deref entirely. It is slightly more verbose, but it preserves the safety guarantee.

The derive macros that make this bearable

Rust’s standard library gives you #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] for free. For arithmetic, you reach for std::ops or a crate like derive_more:

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

#[derive(Debug, Clone, Copy, PartialEq)]
struct Meters(f64);

impl std::ops::Add for Meters {
    type Output = Meters;
    fn add(self, other: Meters) -> Meters {
        Meters(self.0 + other.0)
    }
}

The compiler still generates the same machine code as raw f64 addition. The Add trait is a zero-cost abstraction too. It monomorphizes to inline the operation.

When newtypes are the wrong tool

Not every primitive needs a newtype. If a function takes a timeout_ms: u64 and it is only used locally, wrapping it adds noise without catching real bugs. Reserve newtypes for values that cross API boundaries, persist in databases, or represent concepts where mixing them up has actual consequences.

Serialization also gets weird. If you are using serde, a UserId(u64) serializes as a map {"0": 42} by default, not as 42. You need #[serde(transparent)] to fix that:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);

This is easy to forget and annoying to debug when your JSON API suddenly expects objects instead of numbers.

Start with your public APIs

Find any function that takes multiple arguments of the same primitive type but different meanings. Wrap the ones that cross module or service boundaries. Do not derive Deref. Use #[serde(transparent)] if you are serializing.

If you have cargo-expand installed, run cargo expand on a newtype and look at the generated code. You will see the struct is just the raw value with a different type name. The zero-cost claim is not marketing. It is literally what the compiler emits.