Why we normalize shipping rates across providers
Every shipping provider has its own rate shape. Shippo returns decimal-string amounts and a servicelevel object. EasyPost returns decimal-string amounts with flat carrier and service fields. ShipEngine returns numeric amounts nested under shipping_amount. Field names, data types, and hierarchy all differ.
You can write consumer code that branches on provider and pulls the right field. It works, but your business logic quickly ends up shot-through with provider-specific knowledge. Change a pricing rule, now you touch three code paths. Add a fourth provider, now you touch four.
The alternative is to normalize once, at the adapter boundary, and work with a single shape everywhere downstream. That's what we did in rateship. Here's the design.
The normalized shape
interface NormalizedRate {
provider: "easypost" | "shippo" | "shipengine";
carrier: string; // "UPS", "USPS", "FedEx"
service: string; // "Ground", "Priority Mail"
price_cents: number; // integer cents, always
currency: "USD"; // v2.0 is USD-only
estimated_days: number | null;
estimated_delivery: string | null; // ISO date
rate_id: string; // provider-native id
raw: object; // full provider response
}Four design choices baked into that shape:
1. Integer cents, not decimals
Representing money as floating-point numbers is asking for trouble. 0.1 + 0.2 is not 0.3 in IEEE 754. Worse, providers return rates as strings like "8.40" and multiplying by 100 to get cents runs into the same binary-FP rounding. Math.round(12.345 * 100) returns 1234, not 1235, because 12.345 * 100 evaluates to 1234.4999...
We convert via string manipulation (split on the dot, pad the fractional part, round based on the third decimal). It's more code than Math.round(x * 100) but it's correct.
2. Nullable fields are explicit
Some providers give you estimated delivery dates, some don't. Some give you day counts, some don't. Rather than returning undefined (which hides the difference between "missing" and "we forgot to include it"), we type these fields as number | null and string | null. Consumers pattern-match on null explicitly.
3. The raw field is sacred
Abstractions lose information. Shippo's rate carries things like duration_terms and attributes that don't fit in our normalized shape. EasyPost has delivery guarantees. ShipEngine has trackable flags.
Rather than try to anticipate every field we might want to surface, we include raw: object on every rate. Power users who need provider-specific fields read from raw. The typed fields cover the 95% path; raw handles the rest.
The raw field also backs createLabel(rate). Label purchase is stateful: Shippo needs the rate's object_id, EasyPost needs the parent shipment ID plus the rate ID, ShipEngine uses the rate ID directly. The adapter reads whichever field it needs from raw without asking the consumer to know.
4. Partial success as data, not exceptions
When you fan out to three providers and one fails, you have options. You can throw and discard the two that succeeded. You can silently drop the failure and return what you have. Neither is great.
We return both:
interface RatesResponse {
rates: NormalizedRate[]; // empty if every provider failed
errors: ProviderError[]; // empty if every provider succeeded
}
interface ProviderError {
provider: Provider;
code: ErrorCode; // AUTH_FAILED, TIMEOUT, PROVIDER_ERROR, etc.
message: string;
}The shape forces consumers to see the errors explicitly. It's also the right abstraction for retries: if Shippo returns TIMEOUT, you might retry the whole call. If it returns AUTH_FAILED, you shouldn't.
Thrown exceptions are reserved for developer errors: missing API keys, invalid request shapes, duplicate provider adapters. Those don't land in errors[] because they're not runtime failures, they're bugs.
When normalization hurts
The honest trade-off: normalization is a lossy projection. If Shippo introduces a new rate attribute tomorrow, you won't see it in the typed fields until we ship an SDK update. You can always pull it from raw, but you lose type safety on that field.
That's the right trade-off for the 95% case. Rate quotes, label purchases, tracking events: all of these have a stable, well-understood shape across providers. The long tail of obscure provider-specific features is where raw earns its keep.
What to steal
If you're writing your own shipping adapters rather than using rateship, the patterns above translate directly: integer cents, nullable fields, raw passthrough, errors as data. You'll thank yourself in six months when the provider you picked changes their response shape and your typed fields absorb it without touching consumer code.
Or, you know, just npm install rateship.