Preserve Whole Object
Intent
Section titled “Intent”A caller already holds an object, then digs out two or three of its fields only to pass them, one by one, into a function. Hand the whole object across instead. The argument list shrinks, the function can reach for any field it needs, and if the object grows a new relevant field later, the signature does not have to change.
The smell
Section titled “The smell”At the call site you see a string of accessor calls feeding straight into another call: plot(point.x, point.y), fits(range.low, range.high). The values being unpacked all come from the same object and travel together. The function’s parameter list mirrors the object’s internals, so the two are quietly coupled while pretending to be independent.
Before → After
Section titled “Before → After”A reading is checked against a temperature range. The caller pulls low and high out of the range to pass them in. Instead, pass the range itself and let the check ask it directly.
// Beforefunction fitsRange(reading: number, low: number, high: number): boolean { return reading >= low && reading <= high;}
const ok = fitsRange(today, range.low, range.high);
// Afterinterface Range { low: number; high: number; }
function fitsRange(reading: number, range: Range): boolean { return reading >= range.low && reading <= range.high;}
const ok = fitsRange(today, range);# Beforedef fits_range(reading, low, high): return low <= reading <= high
ok = fits_range(today, range_.low, range_.high)
# Afterdef fits_range(reading, range_): return range_.low <= reading <= range_.high
ok = fits_range(today, range_)// Beforefunc FitsRange(reading, low, high float64) bool { return reading >= low && reading <= high}
ok := FitsRange(today, r.Low, r.High)
// Aftertype Range struct { Low float64 High float64}
func FitsRange(reading float64, r Range) bool { return reading >= r.Low && reading <= r.High}
ok := FitsRange(today, r)// Beforefn fits_range(reading: f64, low: f64, high: f64) -> bool { reading >= low && reading <= high}
let ok = fits_range(today, range.low, range.high);
// Afterstruct Range { low: f64, high: f64,}
fn fits_range(reading: f64, range: &Range) -> bool { reading >= range.low && reading <= range.high}
let ok = fits_range(today, &range);flowchart LR
subgraph Before["Before"]
A["caller pulls low + high<br/>from range"] --> B["fits(low, high)"]
end
subgraph After["After"]
C["caller passes range"] --> D["fits(range)"]
end
Before -.->|"Preserve Whole Object"| After Mechanics
Section titled “Mechanics”- Add a new parameter for the whole object, leaving the existing field parameters in place for now.
- Inside the function, replace each use of an unpacked field with a read from the whole object.
- Update each caller to pass the object and stop passing the individual fields. Run your tests after each one.
- Once every caller is converted, delete the now-unused field parameters.
- Look for logic in the caller that only manipulated those fields — it may belong inside the function, or as a method on the object itself.
When to use / trade-offs
Section titled “When to use / trade-offs”Reach for this when the unpacked fields all belong to one object, when several functions pull the same cluster of fields, or when the function would benefit from access to more of the object than it currently receives. It often reveals Feature Envy — code that fiddles with another object’s data — which you can then move onto the object as a method.
The trade-off is a tighter dependency: the function now knows about the object’s type, not just two plain numbers. If the callee should stay ignorant of that type — for reuse in unrelated contexts, or to avoid a circular dependency — keep the loose parameters. And if a caller does not already have the whole object handy, assembling one just to make the call is a step backward.