merge in practice
An application model often needs fields that do not come from the primary API record: already-decided access flags, provenance metadata, normalized data from another response, or local selection state. Repeated object spreads can assemble those values, but they scatter overwrite rules and weaken the visible transformation pipeline.
merge adds one object or zero-argument source factory to the current model. It is shallow, immutable, and deliberately gives the right-hand source precedence.
Adding application context
This account response needs transport cleanup plus access context that was calculated elsewhere:
const rawAccount = {
account_id: 7,
display_name: 'Research',
member_count: '12',
internal_code: 'north-2',
};
const accessContext = {
canManage: true,
source: 'directory',
} as const;
const toAccount = pipe(
omit(['internal_code']),
rename({
account_id: 'id',
display_name: 'name',
member_count: 'memberCount',
}),
normalize({memberCount: 'number'}),
merge(accessContext),
pick(['id', 'name', 'memberCount', 'canManage', 'source']),
);
const account = toAccount(rawAccount);
Output:
{
id: 7,
name: 'Research',
memberCount: 12,
canManage: true,
source: 'directory',
}
The inferred result is:
{
id: number;
name: string;
readonly memberCount: number | null;
readonly canManage: true;
readonly source: 'directory';
}
merge adds the already-derived context; it does not decide whether the account is manageable. Validation and business policy remain outside ShapeWire's scope.
Combining normalized sources
Normalize each source independently, then merge the resulting application fields:
const rawIdentity = {
user_id: 42,
display_name: 'Ada Lovelace',
};
const rawSubscription = {
plan_code: 'pro',
renews_on: '2027-01-15',
};
const rawPreferences = {
time_zone: 'Europe/London',
email_updates: 'no',
};
const subscription = pipe(
rename({plan_code: 'plan', renews_on: 'renewsAt'}),
normalize({renewsAt: 'isoDate'}),
)(rawSubscription);
const preferences = pipe(
rename({time_zone: 'timezone', email_updates: 'emailUpdates'}),
normalize({emailUpdates: 'boolean'}),
)(rawPreferences);
const toUser = pipe(
rename({user_id: 'id', display_name: 'name'}),
merge(subscription),
merge(preferences),
pick(['id', 'name', 'plan', 'renewsAt', 'timezone', 'emailUpdates']),
);
const user = toUser(rawIdentity);
Output:
{
id: 42,
name: 'Ada Lovelace',
plan: 'pro',
renewsAt: '2027-01-15T00:00:00.000Z',
timezone: 'Europe/London',
emailUpdates: false,
}
Conflicts are explicit overwrites
When both sides contain a key, the right-hand value wins—even when it is undefined. Nested objects are replaced rather than recursively merged:
const applyApplicationState = merge({
name: 'Application name',
profile: {theme: 'light'},
optional: undefined,
});
applyApplicationState({
id: 1,
name: 'API name',
profile: {theme: 'dark', locale: 'en'},
optional: 'from-api',
});
// {
// id: 1,
// name: 'Application name',
// profile: {theme: 'light'},
// optional: undefined,
// }
Boundaries
- Both sides remain unchanged; nested values are not cloned or deeply combined.
- Only own enumerable source fields are copied.
- A source factory runs once per transformed value and its errors propagate.
- The factory receives no arguments. It cannot derive a field from the current left-hand model; compute that field in a preceding transform or supply precomputed context.
- The source and pipeline input are object contracts. Behavior after bypassing those types is not a data-validation feature.
See the merge API reference for the core contract and Failure behavior for conflict and error behavior.