S37: Primitive types where a newtype gives safety (u64 for IDs)
What it looks like
Section titled “What it looks like”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 orderWhy AI agents produce it
Section titled “Why AI agents produce it”- It copies the database and the Java code. An ID column is a
BIGINT, and the Java entity haslong id. The agent usesu64. - 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.
Why it is a problem
Section titled “Why it is a problem”- Mix-ups compile and run.
refund(customer_id, order_id, 1_999)refunds the wrong order, or fails far from the bug. The testworse_mixup_compiles_and_refunds_the_wrong_ordershows it. - Arithmetic that means nothing compiles.
order_id + customer_idandamount_cents * order_idare validu64operations. - Signatures say less.
fn refund(u64, u64, u64)needs parameter names and comments to read.fn refund(OrderId, CustomerId, Cents)documents itself.
The better version
Section titled “The better version”WorseThree
u64parameters. 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}")}Better
OrderId,CustomerId, andCentsnewtypes.Centshas aDisplayimpl 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:
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 typesCents 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.
When it is acceptable
Section titled “When it is acceptable”- 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.
How to detect it
Section titled “How to detect 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.
What to tell your agent
Section titled “What to tell your agent”Introduce newtypes for the IDs and amounts: OrderId(u64), CustomerId(u64),and Cents(u64), with #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] forthe IDs and Ord as well for Cents. Use them in all public functionsignatures and struct fields, and keep the plain u64 only at the databaseor 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.