Error Handling
Every action ends in one predictable result, so you can tell planned-for errors from real crashes.
Every action finishes with a clear, predictable outcome. It never rejects with a raw throw. Your handler
can throw anything it likes: a declared NiceError, an undeclared one, or a plain Error. The runtime
turns all of them into one typed result, so the place you call from always has the same shape to check.
Two questions: expected and isUnhandled
Section titled “Two questions: expected and isUnhandled”When an action fails, there are two separate things you might want to know:
| Question | Where to look | Meaning |
|---|---|---|
| Did this action say it could throw this? | result.expected (on the result) | You listed it with .throws(), so it’s a known, planned-for error. |
| Was this an unexpected crash? | error.isUnhandled (on the error) | Something threw that wasn’t a NiceError at all — a bug or infrastructure failure. |
expected depends on which action you called. The same NiceError can be expected for one action that
declared it, and unexpected for another that didn’t. isUnhandled is a property of the error itself. It’s
true only for the generic wrapper castNiceError puts around a non-NiceError throw, and it stays true
even after the error travels over the network.
The recommended way — runToResult()
Section titled “The recommended way — runToResult()”Get the outcome as a value and check it. No try/catch.
import { matchFirst } from "@nice-code/error";
const result = await act_user.action.getUser.request({ userId }).runToResult();
if (result.ok) { use(result.output);} else if (result.expected) { // result.error is fully typed — it can only be one of this action's declared errors. matchFirst(result.error, { not_found: ({ userId }) => show404(userId), forbidden: () => showForbidden(), });} else { // This action didn't declare this one. Check the error's own flag if you care which kind it is: if (result.error.isUnhandled) alertOncall(result.error); // an unexpected crash / bug / infra issue else report(result.error); // a real NiceError you just didn't .throws()}The outcome is one of three shapes:
type TActionResultOutcome<OUT, DECLARED> = | { ok: true; output: OUT } | { ok: false; expected: true; error: DECLARED } // one of the errors this action declared | { ok: false; expected: false; error: NiceError }; // anything elseexpected is always worked out fresh against the receiver’s own definition. It’s never trusted from the
wire, so an error that arrived over the network is sorted exactly the same way as one thrown locally.
Throw style, with a typed check
Section titled “Throw style, with a typed check”If you prefer runToOutput() (which throws on failure), use the action’s isExpectedError check to narrow a
caught error:
import { castNiceError, matchFirst } from "@nice-code/error";
try { const output = await act_user.action.getUser.request({ userId }).runToOutput();} catch (e) { if (act_user.action.getUser.isExpectedError(e)) { // e is now narrowed to this action's declared errors matchFirst(e, { not_found: ({ userId }) => show404(userId), forbidden: () => showForbidden() }); } else { report(castNiceError(e).toStructuredLog()); }}When the connection itself fails
Section titled “When the connection itself fails”The two questions above sort failures that came back from a handler. A call can also fail because
no transport ever came up, so nothing reached a handler to succeed or throw. You get that as
err_nice_transport with the id initialization_failed, and it carries the two things you need to
act on it:
import { err_nice_transport, EWireConnectFailureKind } from "@nice-code/action";
if (err_nice_transport.isExact(e) && e.hasId("initialization_failed")) { const { endpoint, kind } = e.getContext("initialization_failed");
if (kind === EWireConnectFailureKind.endpoint_answered_non_protocol) { // Something answered, but it isn't the backend — wrong URL, or a proxy/WAF in the way. reportMisroute(endpoint); } else if (kind === EWireConnectFailureKind.endpoint_unreachable) { showOfflineBanner(); // nothing there: server down, wrong port, firewall, offline }}kind is a typed discriminator. Branch on it rather than matching the message text, which is
the brittle habit it exists to replace. endpoint names the URL that transport actually dialed, so
the error tells you where it happened.
If you’re holding a dial error from anywhere else in the stack, classifyConnectFailure(error)
returns the same EWireConnectFailureKind for any error, or unknown when it can’t tell. Both it and
the enum are re-exported from @nice-code/action, so an app using actions never needs to import
@nice-code/wire directly:
import { classifyConnectFailure, EWireConnectFailureKind } from "@nice-code/action";
if (classifyConnectFailure(e) === EWireConnectFailureKind.endpoint_unreachable) retryLater();Error Reference has the full table of kind values,
what each means, and the first thing to check. The Connection covers the
connection lifecycle these failures come from.
Devtools
Section titled “Devtools”The browser devtools panel sorts each failed run by these same two questions.
Based on result.expected, an error is labelled either Expected Error (declared) or Unexpected Error
(undeclared / unhandled). Based on error.isUnhandled, an unexpected crash gets an extra unhandled
badge. The distinction you check in code is the one you see in the timeline.