Skip to content

Replace Primitive with Object

Take a primitive value — a string, a number — that has begun to accumulate rules about how it must be validated, parsed, or formatted, and wrap it in a small dedicated type. The new value object holds the raw data and the behaviour, so the rules live in one place and the type name announces what the value really is.

This is the cure for Primitive Obsession: representing a meaningful domain concept with a raw primitive. A phone number is a string; a money amount is a number; a temperature is a bare float. The symptom is the same logic scattered everywhere the primitive is used — the same regex to validate the phone, the same rounding to handle cents, the same conversion repeated at each site. Each duplication is a place to forget a rule and introduce a bug.

A phone number carried as a raw string, with validation and formatting repeated at every use. After, a small PhoneNumber value type owns the digits and the behaviour.

// Before
function callCustomer(phone: string): void {
const digits = phone.replace(/\D/g, '');
if (digits.length !== 10) throw new Error('invalid phone');
const pretty = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
dial(pretty);
}
// After
class PhoneNumber {
private readonly digits: string;
constructor(raw: string) {
this.digits = raw.replace(/\D/g, '');
if (this.digits.length !== 10) throw new Error('invalid phone');
}
areaCode(): string {
return this.digits.slice(0, 3);
}
formatted(): string {
return `(${this.digits.slice(0, 3)}) ${this.digits.slice(3, 6)}-${this.digits.slice(6)}`;
}
}
function callCustomer(phone: PhoneNumber): void {
dial(phone.formatted());
}
flowchart LR
  subgraph Before["Before — Primitive Obsession"]
    A["phone: string"]
    A --> B["validate() copy-pasted"]
    A --> C["format() copy-pasted"]
    A --> D["areaCode() copy-pasted"]
  end
  subgraph After["After — a value type"]
    E["class PhoneNumber"]
    E --> F["holds the digits"]
    E --> G["isValid()"]
    E --> H["formatted()"]
    E --> I["areaCode()"]
  end
  Before -.->|"Replace Primitive with Object"| After
A raw primitive with scattered rules becomes a value type that owns its behaviour
  1. Create the new class with a single field holding the primitive. Give it a constructor (or parsing function) that takes the raw value and validates it once.
  2. Move one piece of scattered behaviour — a validation, a format, a parse — into a method on the new type.
  3. Replace one use of the raw primitive with the value object, calling the new method instead of the inline logic.
  4. Run your tests.
  5. Repeat: migrate each use site and fold each duplicated rule into a method, one at a time.
  6. Once every site uses the value object, the primitive and its scattered logic disappear from the calling code. The type is now the home for any future rule about that concept.

Promote a primitive once it carries meaning beyond its raw type and once you find yourself repeating logic about it — validation, comparison, formatting, unit conversion. The value object centralises those rules, makes invalid states harder to construct, and lets the type system catch a PhoneNumber passed where a ZipCode was expected.

The cost is one more class and the work of constructing it at the boundaries. For a value used in one place with no rules attached, a raw primitive is perfectly fine — wrapping it would be ceremony with no payoff. The signal to act is repetition: the second or third copy of the same rule is the moment the object earns its keep.

Which smell does Replace Primitive with Object cure?
What is the clearest signal that a primitive should become an object?
Why is validating in the constructor so valuable?
When is a raw primitive still the right choice?