Expand/Contract
Add the new, migrate consumers, remove the old.
When to Use
You need to change an API contract, function signature, data format, or any shared interface where multiple consumers need to be updated. The key insight is that you can't change the interface and all its consumers atomically in a single PR — that creates a massive, error-prone change.
Use this technique when:
- Changing a shared utility function used across multiple components or services
- Evolving a REST endpoint where several clients need to migrate to the new shape
- Renaming or restructuring a data type with widespread usage
The expand phase gives you a period where both the old and new interfaces coexist safely. The contract phase only happens when every consumer has migrated — and the type system will tell you if any callers remain.
The Pattern
- Expand: Add the new field/endpoint/function alongside the old one. Both work. Nothing breaks. No consumers are changed in this PR — it's purely additive.
- Migrate: Update consumers one at a time (or in small, related batches) to use the new interface. Each migration PR is small and focused.
- Contract: Remove the old interface once every consumer has migrated. This PR is safe because the compiler or linter will catch any remaining callers immediately.
Worked Example
A utility function formatDate(date: Date, includeTime: boolean) is used across four components in the app. The team needs to evolve it to formatDate(date: Date, options: FormatOptions) with richer control: custom format strings, timezone support, and relative time output ("3 days ago").
We can't update the signature and all four call sites in one PR — that's a noisy, hard-to-review change that touches six files simultaneously. Instead, we expand (add formatDateV2 alongside the original), migrate (update each component one at a time), then contract (remove the old function and rename formatDateV2 back to formatDate).
PR 1: Add formatDateV2 with options-based API
Define the FormatOptions interface and add formatDateV2 alongside the original formatDate. Both functions are exported and both work. Nothing is removed or changed.
The new function supports everything the old one did — the boolean includeTime: true maps directly to { includeTime: true } — plus relative time output and timezone-aware formatting. This PR is purely additive. All existing consumers keep working without modification.
| line number | line content |
|---|
PR 2: Migrate Dashboard component to formatDateV2
Update the Dashboard component — the first consumer — to use formatDateV2. The import changes from formatDate to formatDateV2, and the two call sites change from the boolean form to the options form.
For date columns where there's no time to show, the calls become simply formatDateV2(order.createdAt) — the includeTime: false default means no options object is needed at all. The original formatDate is still exported; the other three components haven't changed.
| line number | line content |
|---|
PR 3: Migrate ReportExport to formatDateV2
Migrate the ReportExport component — the second consumer. This call site includes the time, so formatDate(report.generatedAt, true) becomes formatDateV2(report.generatedAt, { includeTime: true }).
The change also demonstrates a readability improvement: the named option makes the intent self-documenting. A reviewer no longer has to look up what the second boolean argument means. Two components down, two to go.
| line number | line content |
|---|
PR 4: Migrate ActivityFeed and UserProfile to formatDateV2
Migrate the remaining two consumers in a single PR — they are small, closely related call sites that belong together conceptually.
ActivityFeed takes advantage of the new format: 'relative' option to render timestamps as human-readable durations like "3 days ago." UserProfile uses format: 'long' for a more formal join date presentation. These improvements weren't possible with the old boolean API. The expand phase unlocked them. The diff here shows ActivityFeed.tsx; UserProfile.tsx follows the same one-line import swap pattern.
With this PR, formatDate has zero callers remaining.
| line number | line content |
|---|
PR 5: Deprecate formatDate, add console.warn
Add a JSDoc @deprecated annotation and a console.warn to the old formatDate function. The warning only fires outside production builds, so there's no user-facing impact.
This is a safety net — it catches any call sites we missed during the migration, and signals to other engineers that this function is on its way out. Any browser console output or CI log line showing this warning is a straggler to clean up before the contract step. Leaving formatDate exported without this marker would silently allow new callers to appear.
| line number | line content |
|---|
PR 6: Remove deprecated formatDate, rename formatDateV2 → formatDate
Delete the old formatDate entirely and rename formatDateV2 to formatDate. Update all four component imports from formatDateV2 back to formatDate (shown here for Dashboard.tsx; the other three follow the same single-line import swap).
The final state is cleaner than the original: one function, the richer FormatOptions-based API. TypeScript will immediately catch any remaining callers with the old boolean signature — the type system is now the safety net. The deprecation period is over. The V1 debt is gone.
| line number | line content |
|---|
Common Mistakes
Contracting before all consumers are migrated. Deleting the old interface while callers still depend on it causes immediate runtime errors in production. The expand phase exists precisely to make the migration safe — don't rush the contract step. TypeScript will catch the survivors if you remove the old export before all callers have been moved.
Not writing to both formats during the expand phase when dealing with persisted data. If the old and new interfaces represent data shapes (not just function signatures), you need a dual-write period: write to both the old and new fields so that any reader using either format sees consistent data. Failing to do this causes data divergence that is painful to debug.
Trying to migrate all consumers at once to get it over with. This defeats the purpose of incremental delivery. Large multi-file PRs are harder to review, harder to revert, and riskier to ship. Small, focused migration PRs (one or two files each) are faster to review and easier to reason about. The overhead of an extra PR is much cheaper than a bad merge.