Skip to content

Error Handling

Every action resolves to a deterministic outcome — branch on expected vs unhandled.

Every action always finishes with a clear, predictable outcome — it never just 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 against.

When an action fails, there are two separate things you might want to know:

QuestionWhere to lookMeaning
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 very same NiceError can be expected for one action (which declared it) and unexpected for another (which 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.

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 else

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

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

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); and based on error.isUnhandled, an unexpected crash gets an extra unhandled badge. So the same distinction you check in code is the one you see in the timeline.