Skip to content
rusthacks.dev
Rust

The Newtype Pattern in Rust for Safer Function Signatures

A tuple struct with one field costs nothing at runtime and stops you passing a user id where an order id belongs. Learn construction, derives, Deref pitfalls and the orphan rule workaround.

8 min read
Summarize in AI
Flat illustration for the post “The Newtype Pattern in Rust for Safer Function Signatures”, composed around ownership.

A function signature like transfer(from: u64, to: u64, cents: u64) has three parameters the compiler cannot tell apart, and the day someone swaps two arguments, nothing stops them. The newtype pattern is the Rust answer: wrap each primitive in a one-field struct, and the type checker does the review for you. It costs no memory and no speed. This post shows the bug, the fix, a type that can only hold valid values, the traits you have to add back and what the pattern is for when you do not own the type. All outputs are from Rust 1.99, edition 2024.

The bug a primitive type allows

src/main.rsrust
fn transfer(from_account: u64, to_account: u64, cents: u64) {
    println!("move {cents} cents from {from_account} to {to_account}");
}

fn main() {
    let from = 1001;
    let to = 2002;
    let amount = 5_00;
    transfer(amount, from, to);          // wrong order: compiles, moves the wrong money
}
Text
move 2002 cents from 500 to 1001

The call passes (amount, from, to) where the function wants (from, to, amount). Every argument is a u64, so it compiles, and the program reports moving 2,002 cents from account 500 to account 1,001. In a payment system that is a refund to the wrong person. Code review catches it sometimes. A type system can catch it always, if the types differ.

The fix: one field, distinct type

src/main.rsrust
#[derive(Debug, Clone, Copy, PartialEq)]
struct AccountId(u64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Cents(u64);

fn transfer(from: AccountId, to: AccountId, amount: Cents) {
    println!("move {} cents from {:?} to {:?}", amount.0, from, to);
}

fn main() {
    let from = AccountId(1001);
    let to = AccountId(2002);
    transfer(from, to, Cents(500));
}
Text
move 500 cents from AccountId(1001) to AccountId(2002)

struct AccountId(u64); is a tuple struct with one field, reached as .0. AccountId and Cents both wrap a u64, and to the compiler they are unrelated types. The derives (Debug, Clone, Copy, PartialEq) are written out because a newtype starts with nothing; Copy is there so you can pass an id by value more than once, the way you would a u64. Now repeat the earlier mistake:

src/main.rsrust
#[derive(Clone, Copy)]
struct AccountId(u64);
struct Cents(u64);

fn transfer(from: AccountId, to: AccountId, amount: Cents) {
    println!("move {} from {} to {}", amount.0, from.0, to.0);
}

fn main() {
    transfer(AccountId(1), AccountId(2), Cents(500));
    transfer(Cents(500), AccountId(1), AccountId(2));
}
Text
error[E0308]: arguments to this function are incorrect
  --> src/main.rs:11:5
   |
11 |     transfer(Cents(500), AccountId(1), AccountId(2));
   |     ^^^^^^^^ ----------                ------------ expected `Cents`, found `AccountId`
   |              |
   |              expected `AccountId`, found `Cents`
   |

The compiler finds both mismatches, Cents where an AccountId belongs and the reverse, and suggests the swap that fixes it. The error is caught where the call is written, not in a log file a month later. The related units mistake, adding metres to feet, is in structs and disappears the same way.

It costs nothing

The reason this is a pattern and not a performance worry is that the wrapper vanishes in the compiled program:

src/main.rsrust
use std::mem::size_of;

#[allow(dead_code)]
struct Meters(f64);
#[allow(dead_code)]
struct UserId(u32);
#[allow(dead_code)]
struct Name(String);

fn main() {
    println!("f64 {} / Meters {}", size_of::<f64>(), size_of::<Meters>());
    println!("u32 {} / UserId {}", size_of::<u32>(), size_of::<UserId>());
    println!("String {} / Name {}", size_of::<String>(), size_of::<Name>());
    println!("Option<UserId> {} vs Option<u32> {}", size_of::<Option<UserId>>(), size_of::<Option<u32>>());
}
Text
f64 8 / Meters 8
u32 4 / UserId 4
String 24 / Name 24
Option<UserId> 8 vs Option<u32> 8

Meters is 8 bytes like the f64 it wraps, UserId is 4 like a u32, and Name is 24 like a String. The optimiser sees through the struct, so the code it generates is the code you would get with the bare primitive. Even Option<UserId> is the same 8 bytes as Option<u32>. If you need a guarantee about layout for foreign code, add #[repr(transparent)], which promises that the wrapper has exactly the field's representation.

Valid by construction

Types can carry rules, not just names. If the field is private and the only way to build the type is a function that checks its input, every value of that type is valid, and the rest of the program never re-checks:

src/main.rsrust
use std::fmt;

#[derive(Debug)]
struct EmailError(String);

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

// The only way to get an Email is through `parse`, so every Email is valid.
#[derive(Debug, Clone, PartialEq)]
struct Email(String);

impl Email {
    fn parse(s: &str) -> Result<Email, EmailError> {
        match s.split_once('@') {
            Some((local, domain)) if !local.is_empty() && domain.contains('.') => Ok(Email(s.to_lowercase())),
            _ => Err(EmailError(s.to_string())),
        }
    }

    fn as_str(&self) -> &str {
        &self.0
    }
}

fn send_welcome(to: &Email) {
    println!("sending welcome to {}", to.as_str());
}

fn main() {
    for raw in ["[email protected]", "no-at-sign", "@nope.test"] {
        match Email::parse(raw) {
            Ok(email) => send_welcome(&email),
            Err(e) => println!("rejected: {e}"),
        }
    }
}
Text
sending welcome to [email protected]
rejected: not an email address: no-at-sign
rejected: not an email address: @nope.test

Email can only be made by Email::parse, because the field is private and the module is the only place that can see it (see modules and visibility). send_welcome takes an &Email, and so it can assume the address passed the check; it cannot be handed a raw string. [email protected] was normalised to lower case on the way in, which is also the kind of rule that belongs in one place. The same shape works for NonEmptyString, Percentage, PortNumber and Url. If you want Email::try_from(&str) to work with ?, implement TryFrom as described in TryFrom and TryInto.

What you have to add back

A newtype does not inherit the inner type's behaviour. That is the point and the cost. Adding two Meters needs an Add implementation, and adding a Meters to a Feet needs a conversion:

src/main.rsrust
use std::ops::Add;

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

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

impl From<Feet> for Meters {
    fn from(f: Feet) -> Meters {
        Meters(f.0 * 0.3048)
    }
}

fn main() {
    let a = Meters(10.0);
    let b: Meters = Feet(10.0).into();
    println!("{:?}", a + b);
}
Text
Meters(13.048)

Feet(10.0).into() went through From<Feet> for Meters, which multiplies by 0.3048, so 10 metres plus 10 feet is 13.048 metres. Without the conversion, the compiler refuses the mixed addition:

src/main.rsrust
#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);

fn main() {
    let total = Meters(10.0) + Feet(3.0);
    println!("{total:?}");
}
Text
error[E0369]: cannot add `Feet` to `Meters`
 --> src/main.rs:7:30
  |
7 |     let total = Meters(10.0) + Feet(3.0);
  |                 ------------ ^ --------- Feet
  |                 |
  |                 Meters
  |
note: an implementation of `Add<Feet>` might be missing for `Meters`
 --> src/main.rs:2:1
  |

E0369, no implementation for Meters + Feet. The note tells you which trait is missing. That is the intended friction: you decide what mixed arithmetic means, once, in a function with a name. If you find yourself implementing a dozen operators, the derive_more crate generates them, at the cost of a dependency.

Wrapping a type you do not own

Rust's orphan rule says you may implement a trait for a type only if the trait or the type is local to your crate. Printing a Vec<String> your way is therefore forbidden:

src/main.rsrust
use std::fmt;

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

fn main() {
    println!("{}", vec!["a".to_string()]);
}
Text
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
 --> src/main.rs:3:1
  |
3 | impl fmt::Display for Vec<String> {
  | ^^^^^^^^^^^^^^^^^^^^^^-----------
  |                       |
  |                       `Vec` is not defined in the current crate
  |

The newtype is the way around it, because the wrapper is local:

src/main.rsrust
use std::fmt;

struct Tags(Vec<String>);

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

fn main() {
    println!("{}", Tags(vec!["rust".to_string(), "hacks".to_string()]));
}
Text
rust, hacks

Tags is yours, so impl Display for Tags is allowed. This is how you add formatting, serialisation or comparison to types from other crates. The orphan rule exists so that two crates cannot give conflicting implementations of the same trait for the same type; the E0117 entry goes through the alternatives, including making the trait local. A wrapper that should still behave like the inner type can implement Deref, though overusing that turns a newtype back into inheritance and hides what the type is.

Habits

  • Wrap IDs, units and money in newtypes at the boundary where they enter your program.
  • Keep the field private when the type has rules; make it pub only for plain labelling.
  • Derive the traits you need and implement the operators you mean.
  • Convert at the edges: parse a raw value into the newtype once, and pass the newtype everywhere inside.
  • Name the type for what it means (Cents, not U64Wrapper).

One more benefit is searchability. A grep for AccountId finds every place an account identifier is created, compared or stored, and a rename or a change of representation (from u64 to a UUID) becomes a compiler-guided edit instead of a hunt through code for the right u64. That is the quiet reason experienced Rust teams reach for newtypes even in code where mixing arguments up was never a real risk.

When a newtype is too much

For a function with two parameters of the same type whose order is obvious, such as max(a, b), a wrapper is noise. If the same primitive is used the same way in a hundred places and nobody has ever confused them, the cost in conversions outweighs the safety. A struct with named fields is also a fine answer to the swap problem: Transfer { from, to, cents } makes the names part of the call, and you can combine it with newtypes when the stakes are high enough to want both.

Also inGenerics
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.