Skip to main content

The Result<T, E> contract

The package-wide convention for mutation results.

Rule​

  • Reads throw. A failed getProduct(id) raises. Callers wrap in try / use React or Vue error boundaries / let the framework's request-level error handling take over.
  • Writes return Result<T, E>. A failed addToCart does 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-commerce/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:

  1. Read failures are exceptional. A 500 on getProduct means a shop-wide outage; bubbling it to the framework's error boundary and showing a 5xx page is the right user experience.

  2. 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 Result makes the failure path locally visible; you cannot accidentally forget to handle it.

  3. 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
  4. Async stack traces lose context. Throwing an error five layers deep through async/await loses 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. Result is for runtime failures, not type-system gaps.
  • Loading state — that's a UI concern. Result describes the outcome; loading is the before.
  • Optimistic updates — those are a state-machine concern that composables handle on top of Result-returning services.