Skip to content

feat(result): typed, translatable error handling (@carbon/result)#892

Open
sidgaikwad wants to merge 7 commits into
mainfrom
feat/carbon-result-error-handling
Open

feat(result): typed, translatable error handling (@carbon/result)#892
sidgaikwad wants to merge 7 commits into
mainfrom
feat/carbon-result-error-handling

Conversation

@sidgaikwad

Copy link
Copy Markdown
Collaborator

Summary

Service operations used to fail in an untyped way: services returned raw Supabase { data, error } or threw generic Errors, and the user-facing message was hardcoded English authored at the route. Despite 11 supported locales, every error toast was English — and several services passed the raw PostgrestError straight through, so a raw duplicate key value violates unique constraint … could land in a user's toast.

This introduces @carbon/result: service functions return a typed Result<T, E> whose failure side is a tagged error carrying a Lingui message descriptor. The error is translated at the action boundary into the requester's locale and shown through the existing flash/toast machinery. Developers get exhaustive, typed failure handling; users get messages in their own language; raw database errors never reach them.

Design is recorded in docs/adr/0001, the PRD in docs/prd, and the glossary in CONTEXT.md.

Architecture

graph TD
  R["@carbon/result<br/>Result&lt;T,E&gt; · CarbonError base · 6 core errors"]
  D["@carbon/database<br/>fromQuery / fromTransaction adapters"]
  S["Service layer (*.service.ts)<br/>returns Result · raises errors · domain errors live here"]
  A["Action / loader (conversion boundary)<br/>match → errorFlash(error, i18n)"]
  F["@carbon/auth<br/>FlashResult · flash() · getRequestI18n()"]
  T["Toast — already translated, requester's locale"]
  EB["Route ErrorBoundary"]

  R --> D
  R --> S
  D --> S
  S -->|Result flows up| A
  A --> F
  F --> T
  S -.->|defects: Panic / raw throw| EB
Loading

The error is translated once, at write time, in the requester's locale — not at creation (no presentation concerns in service signatures) and not on the client.

sequenceDiagram
  participant U as User
  participant Act as Action
  participant Svc as approveRequest()
  participant DB as Kysely/Supabase
  U->>Act: POST approve / reject decision
  Act->>Svc: approveRequest(db, id, userId)
  Svc->>DB: pre-flight select
  DB-->>Svc: row missing / not "Pending"
  Svc-->>Act: Result.err(NotFoundError | ConflictError)
  Note over Act: result.isErr()
  Act->>Act: i18n = getRequestI18n(request)
  Act->>Act: errorFlash(error, i18n) → FlashResult (translated)
  Act-->>U: redirect + flash cookie → translated toast
Loading

What's in the box

Package Change
@carbon/result (new) Re-exports better-result; a Lingui-aware CarbonError base (class-level default + call-site override, serializable interpolation values, yieldable in Result.gen) and the six closed-set core errors: NotFoundError, ValidationError, ConflictError, BusinessRuleError, DatabaseError, ExternalServiceError.
@carbon/auth Renamed the old Result type → FlashResult (frees the name for the typed Result). Added errorFlash / successFlash boundary converters that resolve the descriptor via the request i18n and log the raw cause for on-call debugging.
@carbon/database fromQuery(query, { entity, id }) maps PostgrestErrorDatabaseError and no-rows → NotFoundError; fromTransaction(db, fn) maps thrown exceptions → DatabaseError (raw error preserved). Keeps @carbon/result free of Supabase/Kysely types.
apps erp / mes getRequestI18n(request) builds a request-scoped i18n for the boundary.
Approvals pilot approveRequest / rejectRequest / cancelApprovalRequest return Result; the purchase-order, supplier and quality-document routes match → errorFlash; the MCP direct-executor is now Result-aware so a typed failure isn't reported as success.
i18n / docs packages/result/src added to both Lingui catalogs (the pre-commit hook extracted the new ids). Greenfield Rule documented in AGENTS.md.

Conflict vs. BusinessRule

Per the glossary: Conflict = the operation is valid but the current state already blocks/satisfies it (e.g. "already clocked in"); BusinessRule = the operation itself violates an invariant (e.g. "insufficient quantity"). There is no PermissionError — auth and RLS enforce permissions upstream.

See it yourself

pnpm --filter @carbon/result demo

Prints every error and its translations (English = expected, plus Spanish & German) resolved through the exact errorFlash path. Expected output and manual in-app verification steps are in docs/result-error-demo.md.

Tests

Suite Covers
packages/result/src/errors.test.ts core error tags, default/override descriptors, serializable values, Result.gen
packages/database/src/result.test.ts adapter ok / error-shape / no-rows / transaction-throw paths
packages/auth/src/utils/result.test.ts translation + interpolation + override at the boundary; cause logging
apps/erp/.../shared.service.test.ts approvals pilot NotFound + Conflict
packages/result/src/demo.test.ts all errors × locales

35 new tests; @carbon/result, @carbon/database, erp, jobs, mes typecheck clean.

Adoption (Greenfield Rule)

New and materially-modified service functions return Result; untouched legacy code keeps its current style. No big-bang rewrite. The approvals module is the reference example.

Follow-ups (out of scope here)

  • Run pnpm translate to LLM-translate the newly-extracted message ids for all 11 locales (this PR extracts them; the strings ship untranslated, falling back to English).
  • An independent app-local Result type still exists in apps/erp/app/types/index.ts (a FlashResult-shaped sibling); renaming it for full unambiguity is a separate mechanical change.
  • Converting the remaining ~19 service files follows the Greenfield Rule over time.

Service functions now return a typed Result<T, E> whose failure side is a
tagged error carrying a Lingui message descriptor. Errors are translated at the
action boundary into the requester's locale, so error toasts are no longer
hardcoded English and raw Postgres errors never reach users.

- Add @carbon/result: re-exports better-result; a Lingui-aware CarbonError base
  (class-level default + call-site override, serializable values) and the six
  core errors (NotFound, Validation, Conflict, BusinessRule, Database,
  ExternalService).
- Rename the @carbon/auth Result type to FlashResult, freeing the name "Result"
  for the typed Result everywhere.
- Add @carbon/database adapters: fromQuery (PostgrestError -> DatabaseError,
  no-rows -> NotFoundError with entity context) and fromTransaction (thrown
  exception -> DatabaseError, raw error preserved for logging).
- Add errorFlash/successFlash boundary converters in @carbon/auth plus a
  per-request getRequestI18n helper in the erp and mes apps; translation happens
  at write time in the requester's locale.
- Convert the approvals module (approve/reject/cancel) as the reference pilot;
  update the purchase-order, supplier and quality-document routes and make the
  MCP direct-executor Result-aware.
- Register packages/result/src in both Lingui catalogs and document the
  Greenfield Rule in AGENTS.md.

Tests cover the core errors, the database adapters, the boundary converter and
the approvals pilot. Run `pnpm --filter @carbon/result demo` to see every error
and its translations side by side.
@vercel

vercel Bot commented Jun 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
academy Ready Ready Preview, Comment Jun 25, 2026 3:20pm
carbon Ready Ready Preview, Comment Jun 25, 2026 3:20pm
docs Ready Ready Preview, Comment Jun 25, 2026 3:20pm
mes Ready Ready Preview, Comment Jun 25, 2026 3:20pm

Request Review

…anslate error catalogs

- Merge the boundary converters into the existing `error()` / `success()`
  helpers via overloads: pass a tagged `TranslatableError` (or descriptor) plus
  the request i18n to translate at write time. The legacy `(value, string)`
  overload is unchanged, so the existing flash call sites are untouched. i18n is
  optional — without it the English source is used, with `{placeholder}`s still
  interpolated.
- Update the approvals pilot routes to call `error(result.error, i18n)`.
- Translate the six core + two approvals error messages across all 11 locales.
- Remove the throwaway result-demo route.
…mport getPreferenceHeaders from source

- cancelApprovalRequest: pull each Supabase query into a named const, then
  `fromQuery(query, ctx)`. Reads in natural order (build the query, then convert)
  instead of wrapping a long fluent chain inside a call. The named const also
  lets TypeScript infer the row type, so the explicit generics are dropped.
- Import `getPreferenceHeaders` from `@carbon/utils` (its real source) instead of
  `@carbon/react`, where it only resolved via a fragile transitive re-export.
- Tighten the error-message translations: consistent formal register across all
  locales, and gender-safe phrasing for the not-found message (verb/infinitive
  forms rather than gendered adjectives).
…bon-result-error-handling

# Conflicts:
#	apps/erp/app/routes/api+/mcp+/lib/tool-metadata.json
#	packages/locale/locales/de/erp.po
#	packages/locale/locales/en/erp.po
#	packages/locale/locales/es/erp.po
#	packages/locale/locales/fr/erp.po
#	packages/locale/locales/hi/erp.po
#	packages/locale/locales/it/erp.po
#	packages/locale/locales/ja/erp.po
#	packages/locale/locales/pl/erp.po
#	packages/locale/locales/pt/erp.po
#	packages/locale/locales/ru/erp.po
#	packages/locale/locales/tr/erp.po
#	packages/locale/locales/zh/erp.po
…dex access

- Update the demo's inline es/de catalogs and assertions to match the
  production translations (gender-safe not-found, formal register).
- Use `rows.find(...)` instead of array destructuring so the test type-checks
  under `noUncheckedIndexedAccess`.
The merge auto-resolved pnpm-lock.yaml into an inconsistent state (a dangling
`semver@7.7.3` reference with no entry), so `pnpm install --frozen-lockfile`
failed in CI. Regenerated the lockfile so it matches the merged package.json
files; `--frozen-lockfile` now passes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant