Skip to content

Result Types

Every service method and adapter in Porulle returns Result<T> instead of throwing exceptions. This is a deliberate choice with real trade-offs.

type Result<T, E = CommerceError> =
| { ok: true; value: T; meta?: Record<string, unknown> }
| { ok: false; error: E };

Two constructors make it ergonomic:

Ok(product) // { ok: true, value: product }
Err({ code: "NOT_FOUND", message: "Product not found" }) // { ok: false, error: ... }

The ok field is a discriminant. TypeScript narrows the type after a check: if result.ok is true, result.value is available; if false, result.error is available. No casting required.

Exceptions are invisible in the type system. A function signature getProduct(id: string): Promise<Product> says nothing about what happens when the product does not exist. Does it throw? Return null? Return undefined? You have to read the implementation to find out.

getProduct(id: string): Promise<Result<Product>> is explicit. The caller knows the operation might fail, what the failure looks like, and is forced to handle both cases.

Exceptions are easy to forget. A try/catch is opt-in. Forgotten, the exception propagates up the call stack until something catches it or the process crashes. In a multi-layer architecture (route → service → repository → adapter), an uncaught adapter exception surfaces as a generic 500 with no useful context.

Result<T> forces inspection of result.ok before accessing the value. You can still ignore the error — write result.value! with a non-null assertion — but that is a conscious choice, not an oversight.

Exceptions do not compose well. Consider a checkout that calls five services: pricing, inventory, promotions, tax, and payments. With exceptions, each call needs its own try/catch to handle failures differently. With results:

const price = await pricing.resolve(params);
if (!price.ok) return Err(price.error);
const stock = await inventory.getAvailable(entityId);
if (!stock.ok) return Err(stock.error);

More verbose, but the control flow is visible. No hidden jumps. You can read top-to-bottom and understand every path.

The error type is structured, not a bare string:

interface CommerceError {
code: string;
message: string;
details?: unknown;
}

Concrete error classes map to HTTP status codes at the REST boundary:

ClassCodeHTTP Status
CommerceNotFoundErrorNOT_FOUND404
CommerceValidationErrorVALIDATION_FAILED422
CommerceForbiddenErrorFORBIDDEN403
CommerceConflictErrorCONFLICT409
CommerceInvalidTransitionErrorINVALID_TRANSITION422

Services do not know about HTTP. mapErrorToStatus in packages/core/src/interfaces/rest/error.ts handles the translation. A different interface (CLI, RPC, MCP layer) can map the same error differently.

Payment adapter createPaymentIntent returns Promise<Result<PaymentIntent>>, not Promise<PaymentIntent>. A Stripe network timeout is { ok: false, error: { code: "PAYMENT_FAILED", message: "..." } }, not an uncaught rejection.

Payment declines, tax service outages, and storage timeouts are normal operational events, not exceptional conditions. Treating them as return values makes calling code explicit about degraded states.

// With exceptions — concise but opaque
const product = await catalog.getById(id);
const price = await pricing.resolve({ entityId: product.id });
// With Result<T> — verbose but explicit
const productResult = await catalog.getById(id);
if (!productResult.ok) return Err(productResult.error);
const product = productResult.value;
const priceResult = await pricing.resolve({ entityId: product.id });
if (!priceResult.ok) return Err(priceResult.error);
const price = priceResult.value;

The Result version is twice as many lines. This is the cost of making error handling explicit. In practice it is manageable because most service methods have a small number of fallible calls, the pattern is consistent, and the error handling is local — you see it right where the call happens.

Result<T> is the convention for services and adapters, but exceptions are not gone entirely.

Before hooks throw CommerceValidationError to abort the pipeline. This is a control-flow mechanism: throwing inside a transactional pipeline triggers a rollback. The checkout route wraps the before-hook pipeline in a try/catch and converts the exception to an error response.

Unexpected errors (null pointer dereferences, type errors) are not wrapped in Result<T>. They propagate as exceptions and are caught by a top-level error handler that returns a 500. Result<T> is for expected, domain-level failures. An exception means something unexpected happened. This boundary matters: Result<T> means “this operation can fail in known ways.” Keeping it clear prevents the pattern from degenerating into wrapping every possible failure.