Primitive Obsession: When Strings Aren't Enough
Stop using primitive types for domain concepts. Learn how Value Objects and branded types catch bugs at compile time.
Primitive Obsession is the overuse of primitive types (strings, numbers, booleans) to represent domain concepts. An email is not just a string. A price is not just a number. Currency is not just a three-letter code. Using primitives for these loses type safety and domain meaning.
The Danger
// ❌ Everything is a string — nothing prevents mixing them up
function transferMoney(
fromAccountId: string,
toAccountId: string,
amount: number,
currency: string,
) { }
// Oops — swapped the accounts! TypeScript won't catch this.
transferMoney(toAccount, fromAccount, 100, 'USD');
// Oops — negative amount! No validation at the type level.
transferMoney(from, to, -500, 'USD');
// Oops — invalid currency!
transferMoney(from, to, 100, 'FAKE');
Solution 1: Value Objects (Classes)
class Email {
private readonly value: string;
constructor(input: string) {
const normalized = input.toLowerCase().trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
throw new ValidationError(`Invalid email: ${input}`);
}
this.value = normalized;
}
toString(): string { return this.value; }
equals(other: Email): boolean { return this.value === other.value; }
get domain(): string { return this.value.split('@')[1]; }
}
class Money {
constructor(
readonly amount: number,
readonly currency: Currency,
) {
if (amount < 0) throw new ValidationError('Amount cannot be negative');
// Store as cents to avoid floating point
this.amount = Math.round(amount * 100) / 100;
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
format(): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: this.currency,
}).format(this.amount);
}
}
type Currency = 'USD' | 'EUR' | 'GBP';
// Now the compiler prevents mistakes
function transferMoney(from: AccountId, to: AccountId, amount: Money): void {
// from and to are distinct types — can't swap them
// amount is validated — can't be negative
// currency is part of Money — can't be invalid
}
Solution 2: Branded Types (Lightweight)
When full classes feel heavy, TypeScript branded types add safety with zero runtime cost:
// Branded type — a string that's been validated as an email
type Email = string & { readonly __brand: 'Email' };
type AccountId = string & { readonly __brand: 'AccountId' };
type UserId = string & { readonly __brand: 'UserId' };
function createEmail(input: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) {
throw new ValidationError('Invalid email');
}
return input.toLowerCase() as Email;
}
function createAccountId(input: string): AccountId {
if (!/^ACC-\d{8}$/.test(input)) {
throw new ValidationError('Invalid account ID');
}
return input as AccountId;
}
// The compiler now distinguishes between these types
function sendEmail(to: Email, subject: string): void { }
function getAccount(id: AccountId): Account { }
const email = createEmail('[email protected]');
const accountId = createAccountId('ACC-12345678');
sendEmail(email, 'Hello'); // ✅
sendEmail(accountId, 'Hello'); // ❌ Type error!
getAccount(email); // ❌ Type error!
Common Primitives to Replace
| Primitive | Better Type | Why |
|---|---|---|
string for email | Email | Validates format, normalizes case |
number for money | Money | Prevents float issues, enforces currency |
string for URL | URL (built-in) | Validates and parses |
number for percentage | Percentage | Enforces 0-100 range |
string for phone | PhoneNumber | Validates and normalizes format |
string for date | Date or Temporal | Prevents invalid dates |
string for ID | UserId, OrderId | Prevents mixing up different IDs |
When Primitives Are Fine
- Internal local variables:
let count = 0;doesn’t need aCounttype - Framework boundaries: HTTP params will be strings; convert at the boundary
- Simple scripts: Over-typing a 50-line script is overkill
The rule: If a primitive crosses a boundary (function parameter, return type, class field), consider whether a domain type would add safety.
“Obsessive use of primitives is a common code smell. Replacing them with small objects makes your code both safer and more expressive.” — Martin Fowler