# Replace Data Value with Object

> Turn a primitive field that has grown rules and companions into a small class that owns its data and behavior.

- kind: technique
- category: organizing-data
- aliases: Replace Primitive with Object
- canonical: https://aipatterns.dev/refactoring/techniques/replace-data-value-with-object
- inspired-by: https://refactoring.guru/replace-data-value-with-object

## Problem

A field started as a `string` or `number`, but now it has formatting rules, validation and related data. The logic for it is copied across the codebase, and nothing stops an invalid value from being stored.

## Solution

**Create a class for the value, move its data and rules into it, and change the field's type to that class.** Make the class immutable so it behaves like the primitive it replaces.

## Before

```ts
class Contact {
  constructor(public name: string, public phone: string) {}
}

function smsTarget(c: Contact): string {
  const digits = c.phone.replace(/[^\d+]/g, "");
  if (!/^\+\d{8,15}$/.test(digits)) throw new Error(`bad phone ${c.phone}`);
  return digits;
}

function displayPhone(c: Contact): string {
  const digits = c.phone.replace(/[^\d+]/g, "");
  return digits.startsWith("+1") ? `(${digits.slice(2, 5)}) ${digits.slice(5, 8)}-${digits.slice(8)}` : digits;
}
```

## After

```ts
class PhoneNumber {
  private constructor(readonly e164: string) {}

  static parse(input: string): PhoneNumber {
    const digits = input.replace(/[^\d+]/g, "");
    if (!/^\+\d{8,15}$/.test(digits)) throw new Error(`bad phone ${input}`);
    return new PhoneNumber(digits);
  }

  display(): string {
    const d = this.e164;
    return d.startsWith("+1") ? `(${d.slice(2, 5)}) ${d.slice(5, 8)}-${d.slice(8)}` : d;
  }

  equals(other: PhoneNumber): boolean {
    return this.e164 === other.e164;
  }
}

class Contact {
  constructor(public name: string, public phone: PhoneNumber) {}
}

const smsTarget = (c: Contact) => c.phone.e164;
const displayPhone = (c: Contact) => c.phone.display();
```

## Why

- Invalid phone numbers cannot exist past the parse step.
- Normalization happens once, not at every use.
- The type documents intent; a function taking `PhoneNumber` cannot be handed an email by mistake.

## Steps

1. Create the class with one immutable field holding the current primitive. Add a constructor or factory.
2. Change the field's type in the owner. Wrap values where they are assigned; unwrap where they are read (`.e164`). Run the tests.
3. Move duplicated logic (validation, formatting, comparison) into the new class one function at a time. Run the tests after each.
4. Add `equals` and, if needed, `toString` or `toJSON` so comparisons and serialization behave as before.
5. Push the new type outward to parameters and return values where it helps.

## Watch out

- Behavior change: the old code accepted bad phone numbers until they were used. Validating at construction can reject data that loads today. Check stored data, or add a lenient path for legacy records.
- Equality: `===` on two objects compares identity, not value. Replace every `a.phone === b.phone` with `equals`.
- Serialization: `JSON.stringify` now emits `{"e164":"..."}` instead of a string unless you add `toJSON`. Check APIs and caches.
- Don't wrap a primitive that has no rules and no companions. A type alias or branded type may be enough in TypeScript.
- Inverse: inline the class back into a primitive field ([Inline Class](https://aipatterns.dev/refactoring/techniques/inline-class.md)).

## Related

- Fixes [Primitive Obsession](https://aipatterns.dev/refactoring/smells/primitive-obsession.md) and [Data Clumps](https://aipatterns.dev/refactoring/smells/data-clumps.md).
- A special case of [Extract Class](https://aipatterns.dev/refactoring/techniques/extract-class.md).
- Follow with [Change Value to Reference](https://aipatterns.dev/refactoring/techniques/change-value-to-reference.md) if instances must be shared.

## Agent checklist

- [ ] The new type is immutable and has value equality.
- [ ] All identity comparisons on the old field were replaced.
- [ ] Serialization output is unchanged or the change is intended.
- [ ] Existing persisted values pass the new validation, or a migration path exists.
- [ ] Tests pass.
