Appearance
Type boundaries
The SDK keeps two kinds of types apart: what the backend sends, and what consumers see. Backend payloads change on the backend's schedule and carry fields that mean nothing to an integrator. Consumers get a stable model instead.
Naming
| Kind | Convention | Example |
|---|---|---|
| Wire type | Api prefix | ApiExchangePayGetOrderResponse |
| Consumer model | Bare domain noun | ExchangePayOrderStatus, SessionData |
| Consumer method input | Verb…Request / …Options | CreateExchangePayOrderRequest |
| Mapper | to… / from… | toSessionData, toCoinbaseWithdrawal |
Wire types live in src/types/api/*.api.types.ts. Consumer models live in src/types/*.types.ts. Mappers live in src/data-access/.
Not every backend shape needs an Api twin
Prefix a type only when it actually diverges from what consumers should see — snake_case fields, transport-only fields such as jwt or user_intercom_jwt, or nesting that exists for the backend's convenience. ExchangeBalanceItem is already camelCase and carries nothing transport-specific, so it is a plain domain type used on both sides of the boundary. Duplicating it would add a mapper that copies fields one-for-one and nothing else.
Applying the prefix mechanically produces Api twins that are structurally identical to their domain counterpart, which teaches readers to ignore the prefix. Reserve it for the cases where it carries information.
What mappers are for
src/data-access/ holds the boundary: it takes a wire payload and returns a consumer model. Mapping is where fields get renamed, dropped, or defaulted.
ts
// src/data-access/coinbase/coinbase.mappers.ts
export function toCoinbaseWithdrawal(
response: ApiCoinbaseWithdrawResponse,
): CoinbaseWithdrawal {
return {
// ...
nativeAmount: response.native_amount,
destinationAddress: response.to.address,
createdAt: response.created_at,
}
}Enrichment that needs config or session state is not boundary work and stays in its module. toCoinbaseBalances takes balance rows but also folds in the session's wallet minimums, the swap fee rate, and eligibility rules, so it lives in src/modules/coinbase/ rather than src/data-access/.
Where mapping happens
RTK Query caches hold wire payloads. Endpoints declare Api* types and do not map, so a cache entry always matches what the server returned. Modules map at the point they hand data to the consumer:
ts
const currencies = toExchangePaySupportedCurrencies(
result.data ?? [],
this.config.exchangePay.minAmountFiat,
)This matters for restartSession, which writes the refreshed session into the getSession cache. It upserts the raw payload and maps separately for the store, so the cache stays in wire form.
The invariant
No Api* type is reachable from the published type surface.
npm run build enforces this via scripts/check-public-surface.mjs, which walks the emitted .d.ts entry points, resolves the bundler's export aliases, and follows type references transitively. Reachability matters, not just the export list: a public type that references a wire type in one of its fields leaks it just as surely as exporting it directly.
Two things keep the surface clean:
stripInternalis on. Module classes take internal wiring (the Redux store, the RTK API object) in their constructors. Those constructors are marked@internal, so they are dropped from the declarations. Without this, the RTK API type would drag every endpoint's request and response types into the public surface. Consequently,@internalmust only be used to mean "remove this from the published types" — never as a descriptive note.- Internal helpers stay unexported.
getCurrencyDatareturnsApiCoinbaseCurrencyand is therefore private.
When the check fails, it prints the path it followed to reach the type. Fix it by mapping to a domain type, not by loosening the check.