# Primitive Obsession

> Domain concepts like money, emails, IDs or statuses are passed around as raw strings and numbers, so their rules get re-checked everywhere.

- kind: smell
- category: bloaters
- aliases: Stringly Typed Code
- canonical: https://aipatterns.dev/refactoring/smells/primitive-obsession
- inspired-by: https://refactoring.guru/smells/primitive-obsession

## Signs

- The same validation (`/^[^@]+@[^@]+$/`, `amount >= 0`, `code.length === 3`) appears in many places.
- Function signatures read `(string, string, number, string)` and callers swap arguments without the compiler noticing.
- Constants like `STATUS_ACTIVE = 2` or `"pending"` drive `if` and `switch` statements.
- Money is a `number` of unknown unit (dollars or cents) and currency lives in a separate variable, or nowhere.
- Arrays or tuples carry fixed positions with meaning: `row[0]` is the name, `row[3]` is the price.

## Why it happens

Primitives are already there and cost nothing to type. Creating a class for "only an email" feels like overkill when the first use appears. Later the email gains normalization, the amount gains a currency, and the status gains transitions, but by then the primitive is threaded through dozens of signatures and nobody wants to touch them all.

## Example

A transfer function where every argument is a primitive.

```ts title="transfer.ts"
function transfer(fromIban: string, toIban: string, amount: number, currency: string): void {
  if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(fromIban)) throw new Error("bad from IBAN");
  if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(toIban)) throw new Error("bad to IBAN");
  if (amount <= 0 || !Number.isInteger(amount)) throw new Error("amount must be positive cents");
  if (!["EUR", "USD", "GBP"].includes(currency)) throw new Error("unsupported currency");
  ledger.post(fromIban, -amount, currency);
  ledger.post(toIban, amount, currency);
}

transfer("DE89370400440532013000", "FR1420041010050500013M02606", 2500, "EUR");
```

With value objects, each rule lives in one constructor, and invalid values cannot reach `transfer` at all.

```ts title="transfer.ts"
class Iban {
  private constructor(readonly value: string) {}
  static parse(raw: string): Iban {
    const v = raw.replace(/\s+/g, "").toUpperCase();
    if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(v)) throw new Error(`bad IBAN: ${raw}`);
    return new Iban(v);
  }
}

type Currency = "EUR" | "USD" | "GBP";

class Money {
  constructor(readonly cents: number, readonly currency: Currency) {
    if (!Number.isInteger(cents)) throw new Error("cents must be an integer");
  }
  negate(): Money { return new Money(-this.cents, this.currency); }
}

function transfer(from: Iban, to: Iban, amount: Money): void {
  if (amount.cents <= 0) throw new Error("transfer amount must be positive");
  ledger.post(from, amount.negate());
  ledger.post(to, amount);
}
```

## Treatment

- If a single primitive carries rules or formatting (email, IBAN, SKU, percentage), use [Replace Data Value with Object](https://aipatterns.dev/refactoring/techniques/replace-data-value-with-object.md).
- If several primitives always travel together (amount and currency, lat and lng), use [Introduce Parameter Object](https://aipatterns.dev/refactoring/techniques/introduce-parameter-object.md), or [Preserve Whole Object](https://aipatterns.dev/refactoring/techniques/preserve-whole-object.md) when the caller already has the owning object.
- If a coded value only identifies a kind and has no behavior, use [Replace Type Code with Class](https://aipatterns.dev/refactoring/techniques/replace-type-code-with-class.md), or a string literal union or enum in TypeScript.
- If the code value changes behavior, use [Replace Type Code with Subclasses](https://aipatterns.dev/refactoring/techniques/replace-type-code-with-subclasses.md), or [Replace Type Code with State/Strategy](https://aipatterns.dev/refactoring/techniques/replace-type-code-with-state-strategy.md) when the type can change at runtime.
- If an array is used as a record with positional meaning, use [Replace Array with Object](https://aipatterns.dev/refactoring/techniques/replace-array-with-object.md).

## Payoff

- Validation and formatting live in one place, so they cannot drift.
- The type system catches swapped arguments and unit mix-ups.
- New behavior (currency conversion, IBAN masking) has an obvious home.
- Signatures document themselves: `transfer(from: Iban, to: Iban, amount: Money)`.

## When to ignore

- A value that has no rules, no formatting and no unit, such as a free-text comment.
- Throwaway scripts and one-off migrations.
- Hot paths where allocating an object per value matters. In TypeScript, a branded type (`string & { __brand: "Iban" }`) gives compile-time safety with no runtime cost; in Go and Rust, a named type over the primitive is free.

## Agent checklist

- [ ] The value object validates in its constructor or factory, and there is no public way to build an invalid one.
- [ ] Value objects are immutable and compare by value, not identity.
- [ ] Parsing happens at the system boundary (HTTP handler, file reader), and inner code receives the typed value.
- [ ] Duplicate validation scattered through callers is deleted, not left alongside the new type.
- [ ] Serialization (JSON, database columns) still produces the same wire format, verified by tests.
