The Result<T, E> contract
The package-wide convention for mutation results.
Rule
- Reads throw. A failed
getProduct(id)raises. Callers wrap intry/ use React or Vue error boundaries / let the framework's request-level error handling take over. - Writes return
Result<T, E>. A failedaddToCartdoes not throw; it returns{ ok: false, error }. Callers branch on the discriminant.
The shape
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
Constructors:
import { ok, err, tryAsync } from 'propeller-v2-core-ui';
ok({ cartId: 'abc' }); // { ok: true, value: ... }
err(new Error('Out of stock')); // { ok: false, error: ... }
// Lift a throwing async into a Result
const r = await tryAsync(() => services.cart.addItem(item));
// Result<CartItem, Error>
Why split?
A handful of empirical observations from the Propeller-next + Propeller-vue codebases drove the rule:
-
Read failures are exceptional. A 500 on
getProductmeans a shop-wide outage; bubbling it to the framework's error boundary and showing a 5xx page is the right user experience. -
Mutation failures are routine. "Out of stock", "expired session", "insufficient credit" — these are expected failure modes and the UI must surface them in-place, not via an error boundary. Returning
Resultmakes the failure path locally visible; you cannot accidentally forget to handle it. -
TypeScript narrows discriminated unions for free. A
Result<T>forces the consumer to handle both branches:const r = await services.cart.addItem(item);if (!r.ok) { showToast(r.error.message); return; }console.log('Added', r.value); // r.value is typed as T here -
Async stack traces lose context. Throwing an error five layers deep through
async/awaitloses the call-site. Returning an error value keeps it at the boundary where it happened.
What about partial successes?
Some mutations (bulk adds, cart merges) can succeed for some items and fail for others. The pattern:
type AddManyResult = Result<
{ added: CartItem[]; failed: Array<{ item: Item; reason: string }> },
Error
>;
ok: true with a non-empty failed array is valid — the operation
completed, but with caveats. ok: false is reserved for hard failures
(network, auth, malformed input) where no progress was made.
Pattern: lifting at the boundary
When wrapping an SDK call that throws, lift to Result at the seam,
not deep inside a component:
// services/cart.ts (the seam)
async function addItem(item: Item): Promise<Result<CartItem>> {
return tryAsync(() => sdkCart.addItem(item));
}
// component (the consumer)
const r = await services.cart.addItem(item);
// r is already Result — no try needed
This keeps try blocks out of components and centralises error
translation (e.g. mapping SDK error codes to user-facing messages).
What it does NOT replace
- Validation errors — use Zod / Valibot upstream.
Resultis for runtime failures, not type-system gaps. - Loading state — that's a UI concern.
Resultdescribes the outcome; loading is the before. - Optimistic updates — those are a state-machine concern that
composables handle on top of
Result-returning services.