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)