Skip to content
aipatterns.dev

Refactoring techniqueOrganizing Data

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.

Also known as
Replace Primitive 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

TypeScript
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

TypeScript
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).

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.

Treats these smells

See also

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