Skip to content
aipatterns.dev

Code smellBloaters

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.

Also known as
Stringly Typed Code

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.

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.

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

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.

Treatments

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.