API-to-UI composition
Keep transport inconsistencies at the boundary of your application. Define the transform beside the API client, then expose only the clean result to UI code.
import {defaults, mapEach, normalize, omit, pick, pipe, rename} from 'shapewire';
type ApiUser = {
user_id: number;
full_name: string;
created_at: string;
balance: string | number | null;
verified?: string | boolean | null;
};
export const toUser = pipe(
omit(['password_hash', 'internal_notes']),
rename({
user_id: 'id',
full_name: 'name',
created_at: 'createdAt',
}),
defaults({role: 'viewer' as const, verified: false}),
normalize({
createdAt: 'isoDate',
balance: 'number',
verified: 'boolean',
}),
);
export async function getUser(id: number) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`Could not load user ${id}`);
return toUser(await response.json() as ApiUser);
}
The UI receives stable camel-case keys and normalized primitives. Fetching and HTTP error handling stay outside the transform because transforms are synchronous shaping functions, not request clients or validators.
For nested data, target the field explicitly with at rather than expecting recursive schema parsing. The scope guide explains why fetching, validation, business logic, and presentation formatting remain separate.
Selecting and removing fields
Use an allowlist for lightweight list items:
const toUserListItem = pipe(
pick(['user_id', 'full_name', 'avatar_url']),
rename({user_id: 'id', full_name: 'name', avatar_url: 'avatarUrl'}),
);
Apply the same public-user transform to an array response:
const toUsers = mapEach(
pipe(
omit(['password_hash', 'internal_notes']),
rename({user_id: 'id', full_name: 'name'}),
),
);
pick and omit may be used together. This is not a configuration conflict; order follows ordinary left-to-right composition:
const toSafeSummary = pipe(
pick(['id', 'name', 'email', 'token']),
omit(['token']),
);