When the compiler is too trusting
You are building a function to calculate shipping costs. It takes the weight in kilograms and the weight in pounds. Both are floating-point numbers. You write calculate_shipping(weight_kg, weight_lb). A week later, you refactor the function signature and swap the arguments. The compiler says nothing. You run the code. The shipping cost is wrong. The type system missed the mistake because, to the compiler, a kilogram and a pound are just f64.
This happens constantly with IDs. A user ID and a post ID are both u32. You pass them to a database query. You swap them. The query runs. The wrong row gets deleted. The compiler cannot stop you because the types are identical.
Rust gives you a tool to fix this without sacrificing performance. You wrap the type in a struct. The wrapper creates a new, distinct type. The compiler treats the wrapper as completely different from the inner value. This is the newtype pattern.
The newtype pattern
The newtype pattern uses a tuple struct with a single field to wrap an existing type. The wrapper has a different name, so the compiler treats it as a different type. You gain type safety. You keep the performance of the underlying type.
Think of it like a custom box. You can put a ball in a "Ball Box" and a rock in a "Rock Box". Even if the ball and the rock are the exact same size and weight, you cannot put the Ball Box into a slot designed for the Rock Box. The box enforces the distinction.
The syntax is simple. You define a struct with one field. You do not name the field. You use the tuple struct syntax.
struct UserId(u32);
struct PostId(u32);
UserId and PostId are now unrelated types. The compiler will reject any attempt to use one where the other is expected. The wrapper adds zero overhead at runtime. The memory layout is identical to the inner type.
A minimal example
Here is how the pattern prevents the argument swap error.
struct UserId(u32);
struct PostId(u32);
/// Fetches a post for a specific user.
/// The types prevent swapping the arguments.
fn get_post(user: UserId, post: PostId) -> String {
format!("Post {} by User {}", post.0, user.0)
}
fn main() {
let u = UserId(101);
let p = PostId(202);
// This works. The types match the signature.
let result = get_post(u, p);
println!("{}", result);
// This fails to compile.
// get_post(p, u);
// Error[E0308]: mismatched types
// expected `UserId`, found `PostId`
}
The error E0308 appears immediately. The compiler knows PostId is not UserId. You cannot accidentally swap them. The fix is obvious. You must construct the correct type.
Accessing the inner value requires .0. This is the tuple field accessor. It works inside the same module. If you move the struct to a library and try to access .0 from another crate, the compiler blocks you. Tuple fields are private by default. This forces you to expose only the interface you want.
What happens under the hood
The newtype pattern is a zero-cost abstraction. The compiler removes the wrapper entirely during optimization. A UserId takes the same space as a u32. Passing a UserId to a function costs the same as passing a u32. There is no heap allocation. There is no indirection.
The wrapper exists only at compile time. It guides the type checker. Once the code is verified, the wrapper vanishes. You get the safety of distinct types with the speed of raw primitives.
This also means you can derive traits automatically. If the inner type implements Copy, the newtype can too.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Age(u8);
fn main() {
let a1 = Age(25);
let a2 = Age(25);
// Copy happens automatically.
let a3 = a1;
// PartialEq allows comparison.
assert_eq!(a1, a2);
assert_ne!(a1, a3); // Wait, a3 is a copy, so this is false.
// Correction: a1 == a3 is true.
assert_eq!(a1, a3);
}
The #[derive] macro generates the implementations by delegating to the inner field. You get standard behavior for free.
Real-world ergonomics and invariants
Writing UserId(123) everywhere gets tedious. Real code needs ergonomics. You usually add constructors and methods to the newtype. This keeps the inner field hidden and provides a clean API.
You can also enforce invariants at construction time. The newtype becomes a guarantee. If you have a UserId, you know it is valid. You cannot create a UserId with an invalid value unless you use unsafe, which defeats the purpose.
use std::fmt;
/// Represents a validated user ID.
/// Zero is not a valid ID in this system.
struct UserId(u32);
impl UserId {
/// Creates a new UserId if the value is non-zero.
/// Returns None if the value is invalid.
fn new(id: u32) -> Option<Self> {
if id == 0 {
None
} else {
Some(UserId(id))
}
}
/// Returns the inner value.
/// Use this sparingly. Prefer methods that operate on the newtype.
fn into_inner(self) -> u32 {
self.0
}
}
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "User#{}", self.0)
}
}
fn main() {
// Valid ID.
let valid = UserId::new(101).expect("Valid ID");
println!("{}", valid); // Prints "User#101"
// Invalid ID.
let invalid = UserId::new(0);
assert!(invalid.is_none());
}
The new function acts as a gatekeeper. Once you have a UserId, you trust it. You don't need to check for zero again. The type carries the invariant.
Convention aside: The community prefers From traits for infallible conversions. If the conversion never fails, implement From<u32> for UserId. This allows using .into() and integrates better with the standard library. For fallible conversions, stick with new or try_from.
The orphan rule workaround
The newtype pattern solves another major problem: the orphan rule. Rust has a rule that prevents trait implementation conflicts. You can only implement a trait if either the trait or the type is local to your crate.
This means you cannot implement Display for String in your own code. String is from the standard library. Display is from the standard library. Neither is local. The compiler rejects it.
Newtypes bypass this rule. The newtype is local. You can implement any trait for it.
use std::fmt;
/// A username that must start with @.
struct Username(String);
impl Username {
fn new(name: &str) -> Option<Self> {
if name.starts_with('@') {
Some(Username(name.to_string()))
} else {
None
}
}
}
impl fmt::Display for Username {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Custom formatting for usernames.
write!(f, "<{}>", self.0)
}
}
fn main() {
let user = Username::new("@alice").unwrap();
println!("{}", user); // Prints "<@alice>"
}
This is essential for libraries. You can add Serialize, Deserialize, Debug, or custom traits to wrapped types without violating the orphan rule. The newtype gives you a local handle to work with.
Pitfalls and compiler errors
Newtypes add boilerplate. If you are not careful, the boilerplate can leak and make the code ugly.
The biggest pitfall is accessing .0 everywhere. If your code is full of user.0.len() and user.0.to_string(), the newtype has failed. You are treating the wrapper as a transparent alias. Add methods to the newtype instead.
// Bad: Leaking the inner type.
fn print_length(user: &UserId) {
println!("{}", user.0.to_string().len());
}
// Good: Encapsulated method.
impl UserId {
fn len(&self) -> usize {
self.0.to_string().len()
}
}
fn print_length(user: &UserId) {
println!("{}", user.len());
}
Another pitfall is forgetting trait bounds. If you use a newtype in a generic context, you might hit E0277 (trait bound not satisfied). The newtype does not automatically implement traits like PartialEq or Hash unless you derive them or implement them manually.
use std::collections::HashSet;
struct Tag(String);
fn main() {
let mut set = HashSet::new();
// Error[E0277]: the trait bound `Tag: std::hash::Hash` is not satisfied
// set.insert(Tag("rust".to_string()));
}
You must add #[derive(Hash, Eq, PartialEq)] to Tag to use it in a HashSet. The compiler is strict. It does not assume the newtype behaves like the inner type. You must opt in.
Accessing private fields from another module triggers E0616. This is a feature, not a bug. It forces encapsulation. If you need to access the inner value from outside, provide a public method or implement Deref carefully. Implementing Deref to the inner type is usually a bad idea for newtypes. It allows the newtype to be used where the inner type is expected, which breaks the type safety.
// Bad: Deref breaks the newtype guarantee.
// impl std::ops::Deref for UserId {
// type Target = u32;
// fn deref(&self) -> &Self::Target { &self.0 }
// }
// If Deref were implemented, this would compile and defeat the purpose.
// fn expect_u32(n: u32) { println!("{}", n); }
// expect_u32(*UserId(1));
Keep the wrapper tight. Do not implement Deref for newtypes unless you have a very specific reason and understand the consequences.
When to use newtypes
Use the newtype pattern when you need to distinguish semantically different values that share the same underlying representation. Use the newtype pattern when you want to implement a trait for a type you do not own, bypassing the orphan rule. Use the newtype pattern when you need to enforce invariants at construction time and carry that guarantee through the codebase.
Reach for plain types when the semantic distinction does not matter and the boilerplate outweighs the safety benefit. Not every u32 needs a wrapper. If you are just counting loop iterations, a plain u32 is fine.
Reach for phantom types when you need to encode state or variants in the type system without storing data. Newtypes wrap data. Phantom types wrap meaning. They serve different purposes.
Boilerplate is the tax you pay for type safety. Pay it willingly. The compiler will catch mistakes that would otherwise slip into production.