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

PrimitiveBetter TypeWhy
string for emailEmailValidates format, normalizes case
number for moneyMoneyPrevents float issues, enforces currency
string for URLURL (built-in)Validates and parses
number for percentagePercentageEnforces 0-100 range
string for phonePhoneNumberValidates and normalizes format
string for dateDate or TemporalPrevents invalid dates
string for IDUserId, OrderIdPrevents mixing up different IDs

When Primitives Are Fine

  • Internal local variables: let count = 0; doesn’t need a Count type
  • 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