Skip to content

S37: Primitive types where a newtype gives safety (u64 for IDs)

S37Severity: Low

Two different IDs have the same type, so the compiler cannot catch a mix-up.

Clippy: no lint finds this. Look for it in review.

Three u64 parameters with three different meanings:

pub fn refund(order_id: u64, customer_id: u64, amount_cents: u64) -> String {
format!("refund {amount_cents} cents of order {order_id} to customer {customer_id}")
}

A caller with the IDs in the wrong order:

refund(customer_id, order_id, 1_999) // compiles: three `u64`s in any order
  • It copies the database and the Java code. An ID column is a BIGINT, and the Java entity has long id. The agent uses u64.
  • A wrapper type looks like extra work. In Java, a value object for each ID is a class, with equals, hashCode, and an allocation for each value. Many Java codebases skip it, and so does the agent.
  • Nothing forces it. The code compiles and works with u64.
  • Mix-ups compile and run. refund(customer_id, order_id, 1_999) refunds the wrong order, or fails far from the bug. The test worse_mixup_compiles_and_refunds_the_wrong_order shows it.
  • Arithmetic that means nothing compiles. order_id + customer_id and amount_cents * order_id are valid u64 operations.
  • Signatures say less. fn refund(u64, u64, u64) needs parameter names and comments to read. fn refund(OrderId, CustomerId, Cents) documents itself.
  1. WorseThree u64 parameters. Any order compiles.

    pub fn refund(order_id: u64, customer_id: u64, amount_cents: u64) -> String {
    format!("refund {amount_cents} cents of order {order_id} to customer {customer_id}")
    }
  2. BetterOrderId, CustomerId, and Cents newtypes. Cents has a Display impl that shows the unit.

    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    pub struct OrderId(pub u64);
    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    pub struct CustomerId(pub u64);
    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
    pub struct Cents(pub u64);
    pub fn refund(order: OrderId, customer: CustomerId, amount: Cents) -> String {
    format!(
    "refund {amount} of order {} to customer {}",
    order.0, customer.0
    )
    }
    impl fmt::Display for Cents {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    write!(f, "{} cents", self.0)
    }
    }

The same mix-up now does not compile:

Does not compile: E0308
use smells::s37_primitive_obsession::better::{refund, Cents, CustomerId, OrderId};
let customer = CustomerId(42);
let order = OrderId(7);
refund(customer, order, Cents(1_999)); // error E0308: mismatched types

Cents derives PartialOrd and Ord, because amounts are compared. The IDs do not, because “order 7 is less than order 9” means nothing. The derives say which operations make sense.

  • Local values with one meaning: a loop counter, an index into a Vec, a length.
  • Private code where the value never crosses a function boundary.
  • Quick scripts and prototypes. Add the newtypes when the code becomes a product.

Newtypes are most valuable at API boundaries and for IDs, money, and units (Meters, Seconds). A crate such as uom handles units of measure if the code needs many of them. Check it on crates.io before you accept it.

  • No Clippy lint reports this pattern.
  • Look for two or more parameters of the same primitive type with names that end in _id, _cents, _ms, or a unit.
  • Look for comments that give the unit: timeout: u64, // milliseconds. The type could say it: Duration.
  • Look for HashMap<u64, ...> where the key is an ID.
Introduce newtypes for the IDs and amounts: OrderId(u64), CustomerId(u64),
and Cents(u64), with #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] for
the IDs and Ord as well for Cents. Use them in all public function
signatures and struct fields, and keep the plain u64 only at the database
or JSON boundary. Use std::time::Duration for times. Run the tests.

Related: S31: Function with too many parameters (no builder or config struct), and Structs and impl.