Skip to content

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.

There are two cleanly-separated questions when an action fails:

AxisMemberQuestion
Relational (on the result)result.expectedDid this action declare this error via .throws()?
Intrinsic (on the error)error.isUnhandledWas 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.

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 else

expected 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.

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());
}
}

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.

Next: Bi-directional communication →