Skip to content

Checkout API

examples/checkout-api — the edge half: Checkout domain's placeOrder served over oRPC with @unthrown/orpc, tested through a real request/response cycle.

sh
pnpm turbo run test --filter=@unthrown/example-checkout-api

No try/catch

The handler is one mapErrCases call — nothing else:

ts
handlerResult(({ input, errors }) =>
  placeOrder(deps, input.cartId).mapErrCases((matcher) =>
    matcher
      .with(P.tag("CartNotFound"), (e) =>
        errors.NOT_FOUND({ message: e.message }),
      )
      .with(P.tag("CartEmpty"), (e) =>
        errors.BAD_REQUEST({ message: e.message }),
      )
      .with(P.tag("OutOfStock"), (e) => errors.CONFLICT({ message: e.message }))
      .with(P.tag("PaymentDeclined"), (e) =>
        errors.PAYMENT_REQUIRED({ message: e.message }),
      ),
  ),
);

That is safe with no surrounding guard because two things already happened upstream. First, placeOrder's own pipeline never lets a thrown callback escape — the throw→defect net converts it to a Defect before it reaches this handler. Second, handlerResult is the elimination edge: Ok becomes the response, a returned ORPCError is served as a typed, inferable error, and a Defect is rethrown onto oRPC's own defect path. There is nothing left for a try/catch to do here.

Every domain case is named

placeOrder's error channel is CartNotFound | CartEmpty | OutOfStock | PaymentDeclined — four cases, each with its own .with(P.tag(...), ...) arm mapping it to a distinct declared ORPCError. P._ is banned by the dogfooded no-catch-all-pattern lint rule, so there is no wildcard to quietly absorb a case that was never handled. Add a fifth error to CheckoutError and this mapErrCases stops compiling — every call site, this one included, must add its own arm before the build is green again. The tests exercise two of the four domain cases end to end — CartNotFound and PaymentDeclined — each landing on its own distinct ORPCError code (CartEmpty and OutOfStock follow the identical pattern and are covered at the domain layer already; see Checkout domain):

ts
await expect(caller.placeOrder({ cartId: "nope" })).rejects.toMatchObject({
  code: "NOT_FOUND",
});
await expect(caller.placeOrder({ cartId: "cart_1" })).rejects.toMatchObject({
  code: "PAYMENT_REQUIRED",
});

The defect arm: an outage, not a 500 with a leaked stack trace

The fourth outcome the suite pins is not a domain case at all — it is what happens when the payment provider throws instead of returning a PaymentDeclined:

ts
const caller = createCaller(
  deps({
    charge: () => {
      throw new Error("connect ETIMEDOUT");
    },
  }),
);
await expect(caller.placeOrder({ cartId: "cart_1" })).rejects.toMatchObject({
  code: "INTERNAL_SERVER_ERROR",
});

Nothing in router.ts names this case, because it is not a business outcome — nobody writes domain logic for a severed connection. The throw becomes a Defect inside placeOrder, handlerResult rethrows its cause, and oRPC collapses it to a generic INTERNAL_SERVER_ERROR rather than leaking the raw exception. createCaller deliberately routes every call through a real RPCHandler/RPCLink loop (in-memory, no socket) rather than oRPC's in-process shortcut, because that collapse only happens once a call crosses a genuine transport boundary — the same reason @unthrown/orpc's own suite tests it that way. The payoff: an unmodelled failure still cannot escape as an unhandled rejection — it always arrives as a typed, catchable error, just not one you were meant to handle in mapErrCases.

See the oRPC guide for the full server/client bridge.

A fifth outcome, that is not a domain case at all

router.ts also declares input(z.object({ cartId: z.string().min(1) })). An empty cartId never reaches placeOrder — oRPC rejects it during its own input validation, before the handler runs at all. The rejection happens to carry the same BAD_REQUEST code as CartEmpty (both were declared in the same .errors({...}) call), but it is not one of E's four cases and no arm in mapErrCases produced it:

ts
await expect(caller.placeOrder({ cartId: "" })).rejects.toMatchObject({
  code: "BAD_REQUEST",
  message: "Input validation failed",
});

The message is the tell — "Input validation failed", not "cart … has no lines". A domain's E and a transport's input contract are two different things that can coincidentally share a status code; only E is the one unthrown makes exhaustive.

Where to go next

Released under the MIT License.