Changelog - 2026-07-17
Error Module Redesign
Breaking ChangeThe error layer carried the same message in three different shapes: a catalog wrote key + message: string, a throw site wrote message + messageCode + messageArgs, and the response reported normalized { text, code, args } plus a flat messageCode and an extra.messageArgs mirror of the very same values.
Now there is one shape - { text, code, args } - and it is the same at every stage.
This is a breaking change against the published `0.1.1-0`
TErrorDefinition changed shape and ApplicationError.messageCode was removed. Any consumer compiled against @venizia/ignis-inversion@0.1.1-0 needs the migration below. See Migration for the exact rewrites - the bulk is mechanical, but two classes of change are not caught by the compiler.
Overview
- One message shape. A definition, the
getErrorinput andnormalizedall speak{ text, code, args }. - The flat duplicates are gone. No
error.messageCode, noextra.messageArgs, no top-levelmessageCodeon the wire.normalizedis the only source. - Nothing at the throw site had to change.
getError({ message: 'boom', messageCode: 'a.b' })still compiles and behaves exactly as before. - Spreading a definition is safe now - it used to degrade silently.
AppErrorMiddlewarereplaces theappErrorHandlerfunction.- Three crash/silent-loss bugs fixed (see Fixes).
New Features
One shape for a message
File: packages/inversion/src/modules/error/types.ts
Problem: the code and the interpolation args lived in different fields depending on where you stood - key in a catalog, messageCode at a throw site, normalized.code on the wire. Every boundary needed a translation step, and each one was a chance to drop a field.
Solution: message is either the historical string or an object mirroring normalized:
// All three resolve to the same `normalized { text, code, args }`.
getError({ message: 'Only %{n} left', messageCode: 'stock.low', messageArgs: { n: 2 } }); // flat
getError({ message: { text: 'Only %{n} left', code: 'stock.low', args: { n: 2 } } }); // nested
getError({ error: StockErrors.LOW, messageArgs: { n: 2 } }); // cataloguedPrecedence, most specific first:
| Field | Order |
|---|---|
| code | message.code → the definition's message.code → messageCode |
| args | message.args → messageArgs → the definition's message.args |
Benefits:
- Existing flat call sites are untouched - the input is a union, not a replacement.
- A definition can be spread or passed; both resolve identically.
transformreceives the default it replaces, so it can amend one field:s => ({ ...s.message, text }).
The catalogued form takes a partial override
File: packages/inversion/src/modules/error/types.ts
getError({ error: SlugErrors.TAKEN, message: { args: { slug: 've-hoa-nhac' } } }); // amends args
getError({ error: SlugErrors.TAKEN, message: { text: 'Custom' } }); // amends textOmit a field and the definition supplies it.
Fixes
A malformed error crashed inside the constructor
File: packages/inversion/src/modules/error/app-error.ts
getError({ message: 'wrapper', error: 'boom' }) threw a TypeError from inside the error constructor - masking the original failure at the exact moment a catch block was trying to report it. The optional chain guarded definition, not definition.message. It now degrades instead of throwing, and the shape is refused at compile time:
getError({ message: 'wrapper', error: caughtError }); // now a COMPILE ERROR - use `cause`
getError({ message: 'wrapper', cause: caughtError }); // correcterror is the catalogued form's discriminant. It was previously accepted on the free-form branch through the index signature and then silently dropped, because error is a consumed key.
MessageCode guards no longer throw on the input they screen
File: packages/inversion/src/modules/error/message-code.ts
isValid is documented as a guard for a code arriving from outside, yet isValid(123) threw. It now takes unknown and returns false; resolve falls back to MessageCode.DEFAULT for any non-string.
Spreading a definition no longer degrades it
Under the old shape, getError({ ...Errors.X }) put key into extra.key and fell back to core.system_error, while the status and text still arrived - so it looked fine. A definition's message object is now the free-form input shape, so the spread resolves identically to { error: Errors.X }.
Breaking Changes
TErrorDefinition nests its message
File: packages/inversion/src/modules/error/types.ts
// Before
{ key: 'server.core.user.duplicate', statusCode: 409, message: 'Taken: %{email}', messageArgs: {...} }
// After
{ message: { text: 'Taken: %{email}', code: 'server.core.user.duplicate', args: {...} }, statusCode: 409 }TRegisterErrors follows: it indexes ['message']['code'] instead of ['key'].
ApplicationError.messageCode and extra.messageArgs are removed
// Before // After
error.messageCode error.normalized.code
error.extra?.messageArgs error.normalized.args
body.messageCode (HTTP) body.normalized.codenormalized.args is now always populated ({} when empty), so no consumer needs a null check.
appErrorHandler is now AppErrorMiddleware
File: packages/core/src/base/middlewares/app-error/app-error.middleware.ts
// Before
server.onError(appErrorHandler({ logger, rootKey }));
// After
server.onError(new AppErrorMiddleware({ logger, rootKey }).value());It is an IProvider<ErrorHandler>, matching RequestSpyMiddleware. Routing, status codes and the production sanitization boundary are unchanged.
transform receives the normalized default
// Before
transform: s => ({ code: s.messageCode, args: s.extra?.messageArgs ?? {}, text: 'VI' })
// After
transform: s => ({ ...s.message, text: 'VI' })Unchanged
ErrorScopes(AUTH/VALIDATION/BUSINESS/SYSTEM/INTEGRATION) andcategory?.getError/ApplicationError.getError/new ApplicationErrorentry points.isApplicationError- still a shape check, still the only reliable identity test across packages.- Unknown keys still ride the index signature into
extra;causestill reachesError.cause. - The middleware's five branches, their status codes, and the production leak boundary.
Migration
Mechanical - the compiler finds every one. A codemod plus tsc covers these:
| Rewrite | Notes |
|---|---|
key: 'x' → message: { code: 'x', … } | inside every TErrorDefinition |
message: 'text' → message: { text: 'text', … } | inside every TErrorDefinition |
messageArgs: {…} → message: { args: {…} } | inside every TErrorDefinition |
definition.key → definition.message.code | reads of a definition |
['key'] → ['message']['code'] | hand-rolled IErrorKeyRegistry augmentations |
appErrorHandler({…}) → new AppErrorMiddleware({…}).value() | registration |
Not caught by the compiler - check these by hand:
error.messageCodereads. If your client types against a differentApplicationError(a UI package of your own),tscstays green and the read silently yieldsundefined- a blank error toast in production. Grep for.messageCodeand repoint to.normalized.code.error.extra?.messageArgsreads. Same failure mode. Repoint to.normalized.args.as any/ catch-variable reads of either field.
// A client rendering an error changes one line:
translate(error.messageCode, error.extra?.messageArgs); // before
translate(error.normalized.code, error.normalized.args); // after