ADR 0077 — An error definition carries its default message
- Status: Accepted — extends ADR 0058
- Date: 2026-08-17
Context
ADR 0058 fixed the shape of a domain error definition as { status, details? }.
The human-readable text was left to the call site: errors.X({ message: '…' }),
with AppError falling back to the code itself.
Two things followed. Applications keep a separate code → message dictionary
next to the registry — the second source of truth the registry was meant to
remove. And the field was already accepted in practice: excess-property checking
does not fire through a const generic, so defineErrors({ X: { status: 404, message: '…' } }) compiled, the key was frozen into definitions, and the
factory ignored it. A field that can be written, looks right and silently does
nothing is worse than an absent one.
Decision
ErrorDefinition accepts an optional message. Precedence is per-call →
declared → the code (the pre-existing fallback, unchanged for registries that
declare nothing). An empty or whitespace-only declared message throws when the
registry is declared, mirroring the status validation.
FrozenErrorDefinitions normalises the optional key. Without that,
const inference keeps only the keys each definition actually wrote, so a
registry mixing codes with and without message has no common key and
definitions[code].message — the lookup by a code: string variable, which is
the whole point of declaring the text — does not type-check.
hint is deliberately not added. Tool mounts concatenate a per-error hint
with the surface-wide ErrorHintFn, so a declared default would append itself to
every instance of that code and duplicate the advice.
Consequences
- One declaration carries code, status, message and details schema. A consuming application no longer needs a parallel message map.
- The model-facing tool envelope keeps its shape and gains no
messagefield. It stays{ error, details?, _hint? }; changing it would be a breaking transport change and is a separate decision. But the envelope is not unaffected: for a code that declares no details schema the framework already fillsdetailswith{ message }, so such a code now shows the model the declared text where it used to show the code. A code that declares a details schema shows the model no text at all. Both halves are tabulated in the guide and pinned by tests, rather than flattened into “the model does not see it”. - A registry that already carried a string
message(accepted, ignored) changes behaviour: that text now reaches the wire in place of the code — on HTTP, and on the tool path for codes without a details schema. The class is narrow, but it is a behaviour change and is listed as breaking rather than shipped silently. - Registries that declare no
messageare byte-for-byte unchanged.