Skip to content

The error contract — typed, code-discriminated failures

Every error response OnERP’s API sends back has the same shape and the same rule for telling failures apart: a stable code string — not the HTTP status, not the message text, not the exception’s class. It is what the filter writes, what the client parses, and what i18n keys off of.

Every error response body is a lean subset of RFC 9457 Problem Details — no type URI, no title, no application/problem+json ceremony, just the fields that carry real information:

// @onerp/meta/records — the wire envelope
interface ErrorBody {
code: AppErrorCode | (string & {}); // discriminator, e.g. "core.cross_bo_reference"
status: number; // advisory duplicate of the HTTP status
detail: string; // English fallback for logs / non-i18n clients
// + the variant's own params, flattened onto the body
}

The HTTP status is still the coarse signal (4xx vs 5xx, retriable 409); code is the fine signal a caller branches on. A core.cross_bo_reference body, for example, also carries blockers: ReferenceBlocker[] flattened alongside code/status/detail.

Framework codes are closed, domain codes are open

Section titled “Framework codes are closed, domain codes are open”

The core.* namespace is the framework’s catalog — a closed discriminated union in @onerp/meta/records, AppError, that the client narrows on with full type safety. packages/meta/src/records/errors.ts is the source of truth; this is what it holds today:

export type AppError =
| { code: "core.validation_failed"; issues: FieldIssue[] }
| { code: "core.concurrent_modification"; businessObjectName: string; businessObjectId: string }
| { code: "core.stale_revision"; businessObjectName: string; businessObjectId: string }
| { code: "core.cross_bo_reference"; blockers: ReferenceBlocker[] }
| { code: "core.record_locked"; businessObjectName: string; recordStatus: string }
| { code: "core.not_deletable"; businessObjectName: string; recordStatus: string }
| { code: "core.not_archivable"; businessObjectName: string; recordStatus: string }
| { code: "core.not_restorable"; businessObjectName: string; recordStatus: string }
| { code: "core.tree_children"; businessObjectName: string; field: string; count: number }
| { code: "core.invalid_cursor" }
| { code: "core.action_not_found"; action: string }
| { code: "core.function_not_found"; function: string }
| { code: "core.action_not_available"; action: string }
| { code: "core.not_found"; businessObjectName: string; id: string }
| { code: "core.forbidden"; permission?: string }
| { code: "core.unauthorized" }
| { code: "core.no_active_workspace" }
| { code: "core.workspace_not_found" }
| { code: "core.workspace_membership_ended" }
| { code: "core.pdf_render_failed" }
| { code: "core.request_in_flight"; idempotencyKey: string }
| { code: "core.request_rejected" }
| { code: "core.internal" };

A variant that declares params may only be raised where those params are known — the client’s i18n copy interpolates them, so a body missing one renders the placeholder verbatim. That is why the coarse status fallback below emits core.request_rejected, which declares none, rather than core.not_found.

<domain>.* codes are open — a domain adds one the first time it needs it, with no change to @onerp/meta/records. The wire code field is typed AppErrorCode | (string & {}), so a domain code type-checks without widening to string and losing the closed union’s narrowing for core.*. The client renders any code it recognizes through its errors.<code> i18n key, and falls back to the server’s detail for one it does not.

The session, the workspace and the membership between them fail separately, and each failure has its own recovery — which is the whole reason they are three codes rather than one 403:

Code Status What happened Recovery
core.no_active_workspace 403 The account holds no membership anywhere The no-workspace screen: wait for an invitation, or use another account. Never signs out
core.workspace_not_found 401 The session names a workspace that no longer resolves Sign out and back in — the session outlived the workspace
core.workspace_membership_ended 403 The workspace is alive and the session is valid; this account’s membership in it ended Re-point the session at a workspace the account still belongs to and reload; none left lands on the no-workspace screen

core.workspace_membership_ended is a 403 and not a 401 because nothing is wrong with the credential — only with the workspace it is scoped to. Signing the account out would throw away a session that still works for every other workspace it belongs to.

Every throwable error is a thin subclass of AppException (@onerp/core), which wraps Nest’s HttpException and carries the code, the HTTP status, a structured payload, and an English detail. A subclass is about four lines:

export class RecordLockedError extends AppException {
constructor(businessObjectName: string, recordStatus: string) {
super(
"core.record_locked",
422,
{ businessObjectName, recordStatus },
`${businessObjectName} is locked in status '${recordStatus}'`,
);
}
}

The constructor guards two things a subclass author leans on: a payload key named code, status or detail throws, because the body flattens the payload between those envelope fields and would silently clobber one; and message is overwritten with detail, so a log line or a BullMQ failedReason reads the real cause rather than the spelled-out class name.

A domain error is identical but for its <domain>.* code — domains/inventory/src/errors.ts and domains/eu-vat/src/errors.ts hold the ones that ship today. Throwing one is throwing any other exception.

Framework and domain are a namespace split, not two hierarchies: AppException is the only class @onerp/core exports, so nothing outside the package branches on a subclass — and nothing needs to. The class is gone the moment the response is serialized; code is the discriminator on both sides of the wire, and setting it correctly is the subclass’s whole job.

Exactly one place turns a thrown error into a response body: the global @Catch() ErrorFilter in @onerp/core, which delegates to toErrorBody. That is the only function that knows how to shape a body, and it handles every exception shape the pipeline can produce:

  • An AppException (framework or domain) — its own getResponse() already is the ErrorBody.
  • The meta ValidationError or a stray ZodError — folded into core.validation_failed with an issues[] array, whichever validator produced them.
  • Any other Nest HttpException — 401 and 403 map to core.unauthorized and core.forbidden, the only two coarse codes with no required params. Every other 4xx reports core.request_rejected and 5xx reports core.internal, both keeping their own status on the wire — so core.internal is not always a 500.
  • Anything else (a plain Error, a driver error) — core.internal, 500, the real cause logged server-side and never leaked into the body.

Throw sites never shape a body by hand; they throw a typed error (or let Nest’s own exceptions surface) and trust the filter.

i18n: the client renders, the server never does

Section titled “i18n: the client renders, the server never does”

The server sends code + structured params — never a rendered message. Rendering happens client-side, from an errors.<code> i18n namespace. The copy lives in the framework bundle for every code, core.* and <domain>.* alike: a domain ships no message bundle — its translations sit on its defs and cover labels, descriptions and option labels only.

packages/react/src/i18n/en.ts
errors: {
core: {
record_locked: "This %{businessObjectName} is %{recordStatus} and can't be changed.",
},
inventory: {
negative_stock: "This movement would take stock negative for this product and location.",
},
},

describeError resolves any caught value to display copy, translating errors.<code> with the error’s own payload spread in as interpolation params, and falling back to the server’s detail for a code with no key yet — which is where eu-vat’s codes land today. detail is not the primary render path; it’s what a curl, a log line, or an untranslated code reads.

@onerp/client’s errorFrom is the one place a non-ok HTTP response is parsed: any body matching the ErrorBody shape becomes a typed AppErrorException, and anything else (a proxy error, a malformed response) becomes a generic Error carrying only the status.

Callers react to a specific failure with error.is("core.…"), which narrows body to that variant so its params read without a cast:

if (error instanceof AppErrorException && error.is("core.workspace_membership_ended")) {
repointToAnotherWorkspace();
}

is covers the closed core.* catalog only — a <domain>.* code has no typed variant, so branch on body.code for those. Anything not handled falls through to describeError.

A code whose payload is worth showing registers a component in packages/react/src/sdk/feedback/error-details/registry.ts, keyed by the code and typed against the variant it carries. <ErrorDialogProvider> renders the registered detail under the message, so it reaches every surface the policy routes through — an action, a list row, a delete button — not one button that knew to look:

export const errorDetailRegistry = Object.freeze({
"core.cross_bo_reference": ReferenceBlockersDetail,
"core.validation_failed": ValidationIssuesDetail,
}) satisfies Partial<{ [K in AppErrorCode]: ComponentType<ErrorDetailProps<K>> }>;

The registry is partial by design: a code with no entry — every <domain>.* code included — shows its message alone.

core.validation_failed renders its issues this way. A FieldIssue names the business object its path addresses, stamped where the issue is raised, so the dialog reads the field’s label out of that record’s definition in the active locale — and an issue raised on a record the write only touched downstream is labelled as that record’s, not as the one the user was looking at. The request-shape sources carry none: the global ValidationPipe and the controller’s Zod parsing of filter and sort refuse a request, not a record, and those issues show their raw path.

A form’s save and release are where validators run, so a refusal there also lands inline: the form keeps the issues addressed to its own record and shows them on those fields until the user edits them.