Skip to content

Sample: Error Contract

I want to write a handler that handles errors cleanly — no HTTP codes, no JSON bodies, no try/catch chains.

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.

  1. The handler throws or returns a KumikoError via writeFailure(...) or failNotFound(...) / failUnprocessable(...).
  2. The dispatcher translates it into HTTP status + wire format — always { code, i18nKey, message, details?, requestId?, timestamp }.
  3. The client reads error.code (stable category) or error.details.reason (feature-specific subtype).
ClassHTTPUse in handler
ValidationError400Automatic from Zod. Never throw manually, only for validation-hook errors.
AccessDeniedError403”You’re not allowed” — ownership, role check, field lock.
NotFoundError404Entity doesn’t exist. Automatic via failNotFound(entity, id).
ConflictError409State collision without a version (e.g. “paid orders can’t be cancelled”).
VersionConflictError409Optimistic lock — comes out of CrudExecutor automatically. You never throw it.
UnprocessableError422Business rule violated. The reason string describes what.
InternalError500You don’t throw it yourself. The framework wraps unexpected throws automatically.

Instead of

return { isSuccess: false, error: toWriteErrorInfo(new NotFoundError("order", id)) };

write

return failNotFound("order", id);

Likewise: failUnprocessable("reason", details?) and writeFailure(new AnyKumikoError(...)).

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>Reasons const-object per feature
  • Framework reasons (stale_state, invalid_transition, field_access_denied, delete_restricted) come from FrameworkReasonsreuse, don’t duplicate

Both end up in the same wire format. Rule of thumb:

  • Handler top-levelreturn writeFailure(new X()) or the failX(...) helpers. The return type is explicit.
  • Deep inside a helper functionthrow new KumikoError(...). Otherwise you’d have to thread WriteResult through every function signature.

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.

  • throw new Error("string") — becomes InternalError (500), the client sees no helpful error
  • return { isSuccess: false, error: "string" } — not a valid WriteErrorInfo, TypeScript blocks it but it’s a typical pre-v1 pattern
  • Custom class MyError extends Error — also becomes InternalError. Use UnprocessableError + details.reason for feature subtypes
  • Reason strings like "userNotAllowedToEditRecord" (camelCase) or with spaces — the convention is snake_case

The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):

// Error-Contract Sample
//
// Shows: how a feature raises each Kumiko error class in the places a real
// handler would — pre-flight checks, business-rule violations, stale writes,
// access guards. Every pattern here is copy-pasteable into a real feature.
//
// Key idea: the handler only decides *which* Kumiko class fits. The framework
// does the rest — HTTP status, response shape, Zod parsing, cause chain,
// tx rollback. The handler author never has to touch HTTP codes or assemble
// JSON error bodies.
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
import {
createEntity,
createNumberField,
createSelectField,
createTextField,
defineFeature,
defineTransitions,
guardTransition,
} from "@cosmicdrift/kumiko-framework/engine";
import {
AccessDeniedError,
ConflictError,
failNotFound,
failUnprocessable,
NotFoundError,
UnprocessableError,
writeFailure,
} from "@cosmicdrift/kumiko-framework/errors";
import { z } from "zod";
// --- Feature-local reasons: snake_case, no feature prefix needed since the
// feature name is already namespaced by defineFeature("orders-lite", ...).
// The framework convention: one const object per feature that needs its
// own reason strings. For framework-level reasons (stale_state,
// invalid_transition, ...) use FrameworkReasons instead.
export const OrdersLiteReasons = {
alreadyPaid: "already_paid",
alreadyCancelled: "already_cancelled",
emptyCart: "cart_is_empty",
} as const;
// --- Entity with a state machine so we can show guardTransition + version
// conflicts. Prices in cents (integer minor unit — see money-type.md).
const ORDER_STATES = ["draft", "placed", "paid", "cancelled"] as const;
type OrderState = (typeof ORDER_STATES)[number];
const ORDER_TRANSITIONS = defineTransitions({
draft: ["placed", "cancelled"],
placed: ["paid", "cancelled"],
paid: [],
cancelled: [],
});
export const orderEntity = createEntity({
table: "read_errctr_orders",
fields: {
ownerId: createTextField({ required: true }),
status: createSelectField({ options: ORDER_STATES, default: "draft" }),
totalCents: createNumberField({ default: 0 }),
},
transitions: {
status: {
draft: ["placed", "cancelled"],
placed: ["paid", "cancelled"],
paid: [],
cancelled: [],
},
},
});
const orderTable = buildEntityTable("order", orderEntity);
export const ordersLiteFeature = defineFeature("orders-lite", (r) => {
r.entity("order", orderEntity);
// 1) Create — standard path. Zod schema rejects an empty cart; the
// dispatcher converts the ZodIssue into a ValidationError with
// details.fields[] (no handler code needed).
r.writeHandler(
"order:create",
z.object({
totalCents: z.number().int().min(1, "cart_is_empty"),
}),
async (event, ctx) => {
const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
return crud.create(
{ ...event.payload, ownerId: event.user.id, status: "draft" },
event.user,
ctx.db,
);
},
{ access: { roles: ["User", "Admin"] } },
);
// 2) Pay — business rule: only placed orders can be paid. Shows three
// different failure shapes in a single handler:
// - NotFoundError (order doesn't exist)
// - AccessDeniedError (someone else's order, distinct from not_found
// so the caller knows it's a permissions issue)
// - UnprocessableError with reason from OrdersLiteReasons
// - Framework-generated UnprocessableError with reason
// FrameworkReasons.invalidTransition via guardTransition
r.writeHandler(
"order:pay",
z.object({ id: z.uuid() }),
async (event, ctx) => {
const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
// NotFoundError — automatic reason = "order_not_found" via snake-case
// derivation of the entity name.
if (!current) return failNotFound("order", event.payload.id);
const data = current as Record<string, unknown>;
// AccessDeniedError — intentional info-leak-prevention. We *could*
// return NotFoundError here too, but splitting the two lets the
// admin UI distinguish "doesn't exist" from "exists but foreign".
if (data["ownerId"] !== event.user.id && !event.user.roles.includes("Admin")) {
return writeFailure(
new AccessDeniedError({
message: "order is not yours",
i18nKey: "orders-lite.errors.notYours",
details: { reason: "not_yours", orderId: event.payload.id },
}),
);
}
// UnprocessableError with feature reason — "this order is already in
// the terminal paid state". Uses the OrdersLiteReasons const so the
// literal string lives in one place.
if (data["status"] === "paid") {
return failUnprocessable(OrdersLiteReasons.alreadyPaid, {
orderId: event.payload.id,
});
}
// guardTransition throws UnprocessableError with
// reason = FrameworkReasons.invalidTransition. Callers can branch on
// that framework-level reason uniformly across entities.
guardTransition(ORDER_TRANSITIONS, data["status"] as OrderState, "paid");
const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
return crud.update(
{
id: event.payload.id,
changes: { status: "paid" },
version: data["version"] as number,
},
event.user,
ctx.db,
);
},
{ access: { roles: ["User", "Admin"] } },
);
// 3) Cancel — shows ConflictError for a business conflict that isn't a
// transition or a stale write. E.g. a refund window expired; the row
// is fine but the action is no longer allowed.
r.writeHandler(
"order:cancel",
z.object({ id: z.uuid() }),
async (event, ctx) => {
const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
if (!current) return failNotFound("order", event.payload.id);
const data = current as Record<string, unknown>;
if (data["status"] === "cancelled") {
return failUnprocessable(OrdersLiteReasons.alreadyCancelled);
}
if (data["status"] === "paid") {
return writeFailure(
new ConflictError({
message: "paid orders cannot be cancelled — issue a refund instead",
i18nKey: "orders-lite.errors.cannotCancelPaid",
details: { reason: "refund_required", orderId: event.payload.id },
}),
);
}
const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
return crud.update(
{
id: event.payload.id,
changes: { status: "cancelled" },
version: data["version"] as number,
},
event.user,
ctx.db,
);
},
{ access: { roles: ["User", "Admin"] } },
);
// 4) Update — demonstrates the throw-based path (KumikoError raised
// directly, not via writeFailure return). The dispatcher catches it
// and wraps it in a WriteErrorInfo the same as the return path.
// Use this style when a helper deep in the call tree needs to abort
// without threading a WriteResult back up.
r.writeHandler(
"order:rename",
z.object({ id: z.uuid(), nickname: z.string().min(1) }),
async (event, ctx) => {
const [current] = await ctx.db.selectMany(orderTable, { id: event.payload.id });
if (!current) {
// Throwing a KumikoError is equivalent to `return writeFailure(...)`
// — both land on the same wire format. Prefer throws from deeper
// helper functions, writeFailure from the handler's top level.
throw new NotFoundError("order", event.payload.id);
}
if (event.payload.nickname === "banned") {
throw new UnprocessableError("nickname_not_allowed", {
i18nKey: "orders-lite.errors.bannedNickname",
});
}
// No real rename yet — the entity has no nickname column. Return a
// synthetic save context so the handler stays well-typed.
return {
isSuccess: true,
data: {
kind: "save",
id: event.payload.id,
data: current as Record<string, unknown>,
changes: event.payload,
previous: current as Record<string, unknown>,
isNew: false,
entityName: "order",
},
};
},
{ access: { roles: ["User", "Admin"] } },
);
});

📄 On GitHub: samples/recipes/error-contract/src/feature.ts