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
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
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
PhoneNumbercannot be handed an email by mistake.
Steps
- Create the class with one immutable field holding the current primitive. Add a constructor or factory.
- Change the field's type in the owner. Wrap values where they are assigned; unwrap where they are read (
.e164). Run the tests. - Move duplicated logic (validation, formatting, comparison) into the new class one function at a time. Run the tests after each.
- Add
equalsand, if needed,toStringortoJSONso comparisons and serialization behave as before. - 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 everya.phone === b.phonewithequals. - Serialization:
JSON.stringifynow emits{"e164":"..."}instead of a string unless you addtoJSON. 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).
Related
- Fixes Primitive Obsession and Data Clumps.
- A special case of Extract Class.
- Follow with Change Value to Reference 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.
Treats these smells
- Primitive ObsessionDomain concepts like money, emails, IDs or statuses are passed around as raw strings and numbers, so their rules get re-checked everywhere.
- Data ClumpsThe same group of variables travels together through fields and parameters, signaling a missing object that should own them.
- Duplicate CodeThe same logic appears in two or more places, so every fix and change must be made several times and the copies drift apart.
- Long Parameter ListA function takes so many parameters that callers mix up their order and every new option changes every call site.
See also
- Extract ClassSplit a class that does two jobs by moving one job's fields and methods into a new class it holds a reference to.
- Change Value to ReferenceReplace many identical copies of an entity with one shared instance looked up from a registry, so updates are seen everywhere.
- Replace Type Code with ClassReplace a set of string or number codes that don't affect behavior with a dedicated type, so only valid codes can be used.
- Replace Array with ObjectReplace an array whose positions hold different kinds of data with an object that has a named field for each.
- Introduce Parameter ObjectReplace a group of parameters that always travel together with one object type, then move behavior on that group into it.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.