Scalar Types Are Not Enough
Using `string`, `int`, and `bool` for everything gives a false sense of security. The compiler checks the *shape* of your data — but ignores its *meaning* entirely. --- ## The positional parameter problem Say you have a function that processes a seller payout after an order is delivered: ```rust // Rust fn process_order_payout( shop_id: String, customer_id: String, order_id: String, amount: i64, platform_fee: i64, tx_fee: i64, net_amount: i64, ) { ... } ``` Seven parameters. Three IDs, all `String`. Four money values, all `i64`. Now imagine a caller accidentally writes: ```rust process_order_payout( customer_id, // passed where shop_id was expected shop_id, // passed where customer_id was expected order_id, net_amount, // net amount in place of gross tx_fee, platform_fee, amount, ); ``` The compiler doesn't complain. Tests probably pass. The app runs, pays the wrong entity, credits the wrong amount, and nobody notices until a seller asks why they received $35 instead of $5,400. --- ## Structs help — but not enough The natural next step is grouping parameters into a struct. Named fields eliminate positional confusion. But look at what they *don't* prevent: ```rust let params = OrderPayoutParams { shop_id: customer_id, // compiler: fine customer_id: shop_id, // compiler: fine amount: net_amount, // compiler: fine ... }; ``` Every `String` field got a `String`. Every `i64` field got an `i64`. The fact that `customer_id` holds a customer identifier and not a shop identifier? Invisible to the type system. --- ## The fix: wrap every meaningful value in its own type Stop using scalar types for domain concepts. Give each value its own type: ```rust // Rust — newtypes struct ShopId(String); struct CustomerId(String); struct Amount(i64); struct NetAmount(i64); struct OrderPayoutParams { shop_id: ShopId, customer_id: CustomerId, amount: Amount, net_amount: NetAmount, ... } ``` Now try to swap them: ```rust let params = OrderPayoutParams { shop_id: customer_id, // ERROR: expected `ShopId`, found `CustomerId` customer_id: shop_id, // ERROR: expected `CustomerId`, found `ShopId` amount: net_amount, // ERROR: expected `Amount`, found `NetAmount` }; ``` The compiler refuses — not because the data is shaped wrong, but because the *meaning* is wrong. The same idea works in Go and TypeScript: ```go // Go — type definitions (not aliases) type ShopID string type CustomerID string type Amount int64 type NetAmount int64 // ShopID and CustomerID are distinct types. // Passing one where the other is expected = compile error. ``` ```typescript // TypeScript — branded types type ShopId = string & { readonly __brand: "ShopId" }; type CustomerId = string & { readonly __brand: "CustomerId" }; type Amount = number & { readonly __brand: "Amount" }; // The brand only exists at compile time. Zero runtime overhead. ``` --- ## "But now I can't use any normal methods" In Rust, implement `Deref` to transparently expose the inner type's methods — and add validation right in the constructor: ```rust use std::ops::Deref; struct ShopId(String); impl ShopId { pub fn new(id: String) -> Result<Self, String> { if id.is_empty() { return Err("Shop ID cannot be empty".into()); } if !id.starts_with("shop_") { return Err("Shop ID must start with 'shop_'".into()); } Ok(ShopId(id)) } } impl Deref for ShopId { type Target = String; fn deref(&self) -> &String { &self.0 } } let shop = ShopId::new("shop_abc123".to_string())?; println!("{}", shop.len()); // works println!("{}", shop.to_uppercase()); // works too ``` Once a `ShopId` exists in your system, you *know* it's valid. Every function that receives one can skip validation entirely — the constructor already did the work. In Go, defined types inherit the method set of their underlying type, so methods work out of the box. In TypeScript, branded types are purely structural, so all `string` and `number` operations still work with no extra code. --- ## What you actually gain - **Self-documenting code.** A function that takes `ShopId` instead of `String` needs no comment explaining what that parameter is. The type *is* the documentation. - **Refactoring confidence.** Rename a field or change a data flow, and the compiler traces every usage of that type across your entire codebase. - **Validation at the boundary.** Every `ShopId` in your system is guaranteed valid — not because every function checks, but because the constructor checked once. - **Security.** A `RawUserInput` type that must be explicitly converted to `SanitizedHtml` before rendering? Injection prevention enforced by the compiler, not by code review discipline. --- ## The cost is lower than you think These wrapper types are typically two to five lines each. You write them once. The compiler enforces them forever. The alternative is trusting that every developer on your team, across every PR, in every late-night hotfix, will correctly match untyped strings to their intended purpose. That's not engineering — that's hope. > Scalar types describe what data *looks like*. Domain types describe what data *means*. The gap between the two is where bugs live. --- Source: [sot.dev/everything-should-be-typed.html](https://sot.dev/everything-should-be-typed.html)