Sample: Error Contract
I want to write a handler that handles errors cleanly — no HTTP codes, no JSON bodies, no try/catch chains.
What this sample shows
Section titled “What this sample shows”Every Kumiko error class in a real handler context. A single feature orders-lite, four handlers, 7 test cases each demonstrating a typical error situation.
The recipe in 3 sentences
Section titled “The recipe in 3 sentences”- The handler throws or returns a
KumikoErrorviawriteFailure(...)orfailNotFound(...)/failUnprocessable(...). - The dispatcher translates it into HTTP status + wire format — always
{ code, i18nKey, message, details?, requestId?, timestamp }. - The client reads
error.code(stable category) orerror.details.reason(feature-specific subtype).
The classes — when do I use which?
Section titled “The classes — when do I use which?”| Class | HTTP | Use in handler |
|---|---|---|
ValidationError | 400 | Automatic from Zod. Never throw manually, only for validation-hook errors. |
AccessDeniedError | 403 | ”You’re not allowed” — ownership, role check, field lock. |
NotFoundError | 404 | Entity doesn’t exist. Automatic via failNotFound(entity, id). |
ConflictError | 409 | State collision without a version (e.g. “paid orders can’t be cancelled”). |
VersionConflictError | 409 | Optimistic lock — comes out of CrudExecutor automatically. You never throw it. |
UnprocessableError | 422 | Business rule violated. The reason string describes what. |
InternalError | 500 | You don’t throw it yourself. The framework wraps unexpected throws automatically. |
Convenience helpers
Section titled “Convenience helpers”Instead of
return { isSuccess: false, error: toWriteErrorInfo(new NotFoundError("order", id)) };write
return failNotFound("order", id);Likewise: failUnprocessable("reason", details?) and writeFailure(new AnyKumikoError(...)).
Reason codes — the convention
Section titled “Reason codes — the convention”When your feature needs its own differentiation (e.g. already_paid vs. already_cancelled), use UnprocessableError or ConflictError and set details.reason:
export const OrdersLiteReasons = { alreadyPaid: "already_paid", alreadyCancelled: "already_cancelled",} as const;
return failUnprocessable(OrdersLiteReasons.alreadyPaid, { orderId });Rules:
snake_case, no spaces- One
<Feature>Reasonsconst-object per feature - Framework reasons (
stale_state,invalid_transition,field_access_denied,delete_restricted) come fromFrameworkReasons— reuse, don’t duplicate
Throw vs. writeFailure
Section titled “Throw vs. writeFailure”Both end up in the same wire format. Rule of thumb:
- Handler top-level →
return writeFailure(new X())or thefailX(...)helpers. The return type is explicit. - Deep inside a helper function →
throw new KumikoError(...). Otherwise you’d have to threadWriteResultthrough every function signature.
Cause chain
Section titled “Cause chain”When you throw a KumikoError that has another error as its cause:
try { await externalApi.call();} catch (e) { throw new ConflictError({ message: "upstream rejected the sync", i18nKey: "orders-lite.errors.upstreamReject", details: { reason: "upstream_reject" }, cause: e instanceof Error ? e : undefined, });}The chain lands in the log (for forensics), but not in the response to the client. No manual filter required.
What you should not do
Section titled “What you should not do”throw new Error("string")— becomesInternalError(500), the client sees no helpful errorreturn { isSuccess: false, error: "string" }— not a validWriteErrorInfo, TypeScript blocks it but it’s a typical pre-v1 pattern- Custom
class MyError extends Error— also becomesInternalError. UseUnprocessableError+details.reasonfor feature subtypes - Reason strings like
"userNotAllowedToEditRecord"(camelCase) or with spaces — the convention issnake_case
Further reading
Section titled “Further reading”- Full class definitions:
packages/framework/src/errors/classes.ts - Gold-standard integration test:
packages/framework/src/__tests__/error-contract.integration.ts - Architecture plan:
docs/plans/architecture/error-contract.md
Source path: samples/recipes/error-contract/README.md