Rust Newtype Pattern for Stronger Domain Types
Wrap a primitive in a one-field tuple struct so the type system can distinguish UserId from OrderId. Free strong typing with zero runtime cost.
Detailed Explanation
The Newtype Pattern with serde
A newtype is a tuple struct with exactly one field. It looks pointless at first — UserId is just an i64 underneath — but it lets the compiler reject calls like assign_to(order_id) when the function expects a UserId.
Starting JSON
{
"user_id": 1234567,
"order_id": 9876543
}
Auto-generated output
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub user_id: i32,
pub order_id: i32,
}
Promoted to newtypes
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct UserId(pub i64);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct OrderId(pub i64);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
pub user_id: UserId,
pub order_id: OrderId,
}
Transparent serialization
By default, serde serializes a tuple struct as a single-element array: UserId(1) becomes [1]. To make it serialize as the inner value (1), add #[serde(transparent)]:
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserId(pub i64);
Now the round-trip JSON looks identical to a bare i64, and Rust still rejects accidental mixing.
Adding domain methods
Newtypes can have methods, traits, and constructors:
impl UserId {
pub fn new(id: i64) -> Result<Self, &'static str> {
if id <= 0 { return Err("id must be positive"); }
Ok(UserId(id))
}
}
This pattern is the foundation of "parse, don't validate" — once you have a UserId, you know it is valid and you cannot accidentally construct a bad one.
Use Case
Domain-driven design, financial calculations, and any codebase with multiple ID types benefit from newtypes. They prevent the entire class of 'wrong ID passed to wrong function' bugs at compile time.