Skip to content

Frontend Guide — Error Contract

Audience: frontend engineers consuming this API. The one rule: to show an error to a user, render messages[lang]. That's it. No per-code logic, no template table, no interpolation, no reading params.

The backend renders every error message — fully, in every supported language, with all the specifics already woven in ("This room type is used by 3 rooms and cannot be deleted"). You display the string. The days of the frontend maintaining a code → template map and stitching in params are over.


1. The response shape

Every error response (4xx/5xx with a body) looks like this:

{
  "statusCode": 409,
  "code": "ROOM_TYPE_IN_USE",
  "messages": {
    "en_US": "This room type is used by 3 rooms and cannot be deleted",
    "it_IT": "Questo tipo di aula è utilizzato da 3 aule e non può essere eliminato"
  },
  "field": "identity.email",     // optional — see §5
  "data": { /* optional — collections, see §4 */ },
  "params": { "roomTypeId": "…", "roomCount": 3 },  // DEPRECATED — do NOT read (see §6)
  "timestamp": "2026-07-03T10:00:00.000Z",
  "path": "/api/v1/room-types/abc"
}
Field Use it for Notes
messages Displaying text. { en_US, it_IT }, fully rendered. The only field you need for display.
code Behavior branching (auth redirect, retry) — never text. Stable, machine-readable. See §5.
field Behavior: focusing/placing an error on a form input. Body-relative dotted path. Optional.
data Behavior + collection text (each leaf carries its own messages). See §4.
statusCode HTTP status.
~~params~~ Nothing. Deprecated, being removed. Do not build text from it. See §6.
~~message~~ Gone. Removed. If your code reads error.message, fix it.

2. Picking the language

lang is 'en_US' | 'it_IT'. Resolve it from the user's locale once, app-wide:

type Lang = 'en_US' | 'it_IT';

function resolveLang(userLocale: string): Lang {
  return userLocale.toLowerCase().startsWith('it') ? 'it_IT' : 'en_US';
}

More languages may be added later — they appear as new keys in messages, no contract change. Never hardcode messages.en_US in display code; always index by the resolved lang.


3. The common case (a single error)

99% of errors are a single message. Render it:

function showError(err: ApiError, lang: Lang) {
  toast(err.messages[lang]); // done.
}

No switch (err.code). No lookup table. The message already says everything the user needs.


4. The collection case (many errors in one response)

Four codes return a list of sub-errors — form validation, file import, curriculum selection, and prerequisite gaps. Each leaf carries its own fully-rendered messages. You render the list; you never build the text.

The leaves live under data.<arrayField>:

code data.<array> Leaf shape (relevant bits)
VALIDATION_FAILED data.errors { field, rule, allowedValues?, messages }
IMPORT_VALIDATION_FAILED data.errors { code, column, rows, allowedValues?, messages }
SELECTION_VALIDATION_FAILED data.violations { kind, messages }
SELECTION_WINDOW_PREREQ_FAILED data.gaps { departmentName, gradeName, activeStudentCount, messages }

Leaf locator notes:

  • allowedValues (both validation and import leaves) is present only for enum-valued failures — a string[] of the accepted values, if you want to render a "pick one of …" hint next to the message.
  • Import rows is a compressed-ranges string (e.g. "3, 7, 12-46"), already human-readable — display as-is, don't parse it.
  • rule (validation) and code (import) are discriminators, not display text — see §6.

Form validation → place each error on its input

{
  "code": "VALIDATION_FAILED",
  "messages": { "en_US": "identity.email: Must be a valid email address; identity.firstName: This field is required", "it_IT": "…" },
  "data": {
    "errors": [
      { "field": "identity.email",     "rule": "isEmail",   "messages": { "en_US": "Must be a valid email address", "it_IT": "…" } },
      { "field": "identity.firstName", "rule": "isNotEmpty", "messages": { "en_US": "This field is required",        "it_IT": "…" } }
    ]
  }
}

The VALIDATION_FAILED envelope messages is composed from the leaves — up to 3 field-prefixed leaf messages joined with ;, plus a localized (+N more) suffix beyond that. So even a plain toast(err.messages[lang]) shows the specifics. For the best form UX, still place each leaf on its input:

// Envelope message = form-level banner/toast (already carries the specifics).
// Per-field = attach to the input at `field`.
for (const e of err.data.errors) {
  formApi.setFieldError(e.field, e.messages[lang]); // `field` is the dotted body path
}

Note: the field prefix in the composed envelope is the raw request-body key (identity.email), not a human label — prefer the per-leaf placement above in form contexts where that reads poorly.

field is the request-body-relative dotted path (e.g. identity.email, lunchShifts.0.startTime) — the same path your form uses to address the input.

Prerequisite gaps → render the list

{
  "code": "SELECTION_WINDOW_PREREQ_FAILED",
  "messages": { "en_US": "Some grades with active students still lack a published curriculum", "it_IT": "…" },
  "data": {
    "gaps": [
      {
        "departmentName": "Science", "gradeName": "Grade 9", "activeStudentCount": 12,
        "messages": { "en_US": "Science · Grade 9: 12 active students still have no published curriculum", "it_IT": "…" }
      }
    ]
  }
}
<>
  <h4>{err.messages[lang]}</h4>        {/* envelope = header */}
  <ul>{err.data.gaps.map((g, i) => <li key={i}>{g.messages[lang]}</li>)}</ul>
</>

One helper for all four

/** Returns the human-readable lines for any error: leaf messages if it's a collection, else the envelope. */
function errorLines(err: ApiError, lang: Lang): string[] {
  const leaves =
    err.data?.errors ?? err.data?.violations ?? err.data?.gaps;
  return Array.isArray(leaves) && leaves.length > 0
    ? leaves.map((l) => l.messages[lang])
    : [err.messages[lang]];
}

Note: the only frontend logic here is "iterate the list and read each messages[lang]" + optionally place by field. You still never construct or translate copy.


5. Optional behavior hooks (not text)

code, field, and data exist for behavior, not for building messages. Use them when you want richer UX — but the app is fully functional without them:

  • code — a small, fixed set of transport/flow reactions, written once and never touched per new error:
  • AUTH_TOKEN_EXPIRED → attempt a silent token refresh, then retry; AUTH_TOKEN_INVALID / AUTH_TOKEN_MISSING / AUTH_TOKEN_REUSE / UNAUTHORIZED → redirect to login.
  • RATE_LIMITED → back off / disable retry briefly (global limit 10 req/60s, login 5 req/60s).
  • 5xx / INTERNAL_ERROR → offer a "retry" affordance.
  • Do not grow a switch with a case per domain code. Domain codes all collapse to "show messages[lang]".
  • field — focus/scroll to and highlight the offending input (§4).
  • data — highlight the offending rows/entities (e.g. the departments in gaps), drive a "fix it" flow.

When is field present?

Every error belongs to one of four locating categories, so "does this error point at an input?" is deterministic, not per-code trivia:

Category field What you get
Located — a specific input is wrong ✅ exact body-relative dotted path (array indices numeric: lunchShifts.0.startTime) place the message on that input
Container + identifiers — something is missing from a collection ✅ path to the containing collection plus identifiers in data naming the absent items
Request-level — a whole-request precondition failed banner/toast only
Entity-state / auth — not about the payload at all (NOT_FOUND, permission denials, *_IN_USE conflicts) banner/toast only

field paths are always relative to this request's body root — the same rule violated via a bulk endpoint vs. a single-resource endpoint yields a different prefix (departments.2.curricula.0.grid.optionBlocks.0 vs. optionBlocks.0). Resolve against the payload you actually sent.


6. What NOT to do

  • Don't build text from code. No errorMessages[code] lookup table. Delete it.
  • Don't read params for display. params is deprecated and will be removed. It carries raw ids/discriminators for transitional branching only — never user text. Anything you'd want to show is already in messages.
  • Don't read leaf rule / reason / code for display. Same as params — discriminators, being retired. The leaf's messages already reflects them.
  • Don't read error.message. It was removed. Use error.messages[lang].
  • Don't interpolate. No {{placeholder}} handling on the frontend. If you ever see a literal {{…}} in a rendered message, that's a backend bug — report it (a drift test should have caught it).
  • Don't hardcode messages.en_US in display paths — index by the resolved lang.

7. Suggested TypeScript types

export type Lang = 'en_US' | 'it_IT';

export interface LocalizedMessage {
  en_US: string;
  it_IT: string;
}

export interface ErrorLeaf {
  field?: string;              // VALIDATION_FAILED
  messages: LocalizedMessage;
  [k: string]: unknown;        // discriminators (rule/kind/code) — behavior only, ignore for text
}

export interface ApiError {
  statusCode: number;
  code: string;
  messages: LocalizedMessage;
  field?: string;
  data?: {
    errors?: ErrorLeaf[];
    violations?: ErrorLeaf[];
    gaps?: ErrorLeaf[];
    [k: string]: unknown;
  };
  /** @deprecated Do not use — being removed. Text lives in `messages`. */
  params?: Record<string, unknown>;
  timestamp: string;
  path: string;
}

8. Edge cases

  • No response body (network failure, timeout, some 502/504): there's no messages to read. Keep one frontend-owned generic fallback string ("Something went wrong. Please try again.") for this case only. Any response with a JSON body has messages.
  • Unmapped/framework errors (a raw 404 route, a generic 400): the backend still renders a generic messages (e.g. NOT_FOUND, BAD_REQUEST). You still just render messages[lang].
  • HTTP_<status> codes (e.g. HTTP_500): a rare last-resort fallback for framework errors with no canonical code. These carry the same English string in both language keys — still render messages[lang] and nothing breaks, but if you see one in the wild, report it (each occurrence is a backend gap to close).
  • Enum-flavored values in some messages (a weekday, a status, a step name) may currently render in a raw form (e.g. MONDAY, ACTIVE). That's a known cosmetic item on the backend, not something to "fix" on the frontend by remapping.

9. Migration checklist (from the old model)

If your codebase still assumes the frontend owns error text:

  • Delete the code → message template table / i18n error dictionary.
  • Remove all {{param}} interpolation for errors.
  • Replace error.message reads with error.messages[lang].
  • Replace per-code display switches with errorLines(err, lang) (§4 helper).
  • Keep only the behavior code handling (auth redirect, retry, rate-limit) — §5.
  • Stop reading params / leaf rule / reason for anything user-visible.
  • Wire form validation to place data.errors[].messages[lang] by field.

10. Transitional note (why params is still on the wire)

params and the leaf discriminators (rule / reason / code) are still emitted today so nothing breaks while the frontend migrates. They are deprecated and will be removed in a later backend phase once every consumer is off them. Treat them as invisible: if you're reaching for params to render something, the value you want is already in messages — if it isn't, that's a backend gap to report, not a reason to read params.

The backend guarantees message completeness with a build-time test (a message cannot ship missing a piece of info it's supposed to carry), so you can rely on messages[lang] being sufficient.


Backend source of truth: docs/06-error-handling.md (§2 "Messages are self-contained"), catalog in src/common/i18n/error-messages.catalog.ts.