Error Handling
Every action resolves to a deterministic outcome — branch on expected vs unhandled.
Every action resolves to a deterministic outcome — it never rejects with a raw throw. A handler can
throw whatever it likes (a declared NiceError, an undeclared one, or a plain Error); the runtime
funnels all of them into one typed result so the call site always has the same shape to branch on.
Two axes: expected and isUnhandled
Section titled “Two axes: expected and isUnhandled”There are two cleanly-separated questions when an action fails:
| Axis | Member | Question |
|---|---|---|
| Relational (on the result) | result.expected | Did this action declare this error via .throws()? |
| Intrinsic (on the error) | error.isUnhandled | Was it a wrapped foreign throw we never accounted for (a bug / infra failure)? |
expected is relational because the same NiceError can be expected for one action (which declared it)
and unexpected for another (which didn’t). isUnhandled is intrinsic to the error — it’s true only for
the generic wrapper castNiceError produces around a non-NiceError throw, and it survives transport.
Result-based — runToResult()
Section titled “Result-based — runToResult()”The idiomatic path: get the outcome and branch on it. No try/catch.
import { matchFirst } from "@nice-code/error";
const result = await userDomain.action.getUser.request({ userId }).runToResult();
if (result.ok) { use(result.output);} else if (result.expected) { // result.error is the declared union — fully typed against this action's .throws() matchFirst(result.error, { not_found: ({ userId }) => show404(userId), forbidden: () => showForbidden(), });} else { // Not declared by this action. Refine with the error's own flag if you care: if (result.error.isUnhandled) alertOncall(result.error); // foreign throw / bug / infra else report(result.error); // a real NiceError you didn't .throws()}The outcome is a discriminated union:
type TActionResultOutcome<OUT, DECLARED> = | { ok: true; output: OUT } | { ok: false; expected: true; error: DECLARED } // narrowed to the declared union | { ok: false; expected: false; error: NiceError }; // anything elseexpected is always re-derived against the receiver’s own schema on hydrate — it is never trusted
from the wire — so a result that crossed a carrier classifies the same as a local one.
Throw-style with a typed guard
Section titled “Throw-style with a typed guard”If you prefer runToOutput() (which rethrows on failure), narrow caught errors with the action’s
isExpectedError guard:
import { castNiceError, matchFirst } from "@nice-code/error";
try { const output = await userDomain.action.getUser.request({ userId }).runToOutput();} catch (e) { if (userDomain.action.getUser.isExpectedError(e)) { // e is narrowed to the declared union matchFirst(e, { not_found: ({ userId }) => show404(userId), forbidden: () => showForbidden() }); } else { report(castNiceError(e).toStructuredLog()); }}Devtools
Section titled “Devtools”The browser devtools panel classifies each failed run from these same two
axes: an action error is labelled Expected Error (declared) vs Unexpected Error (undeclared /
unhandled) from result.expected, and a foreign-throw wrapper carries an unhandled badge from
error.isUnhandled. So the same distinction you branch on in code is the one you see in the timeline.