Error
getError() builds an ApplicationError carrying an HTTP status and a machine-readable code - the house rule is getError, never new Error.
In one example
import { getError, HTTP } from '@venizia/ignis-helpers';
throw getError({
message: 'User not found',
statusCode: HTTP.ResultCodes.RS_4.NotFound,
messageCode: 'core.user.not_found',
});The framework's AppErrorMiddleware catches ApplicationError instances and formats them into a consistent JSON response - see Common tasks below.
How it works
Three equivalent entry points.
getError(opts),new ApplicationError(opts), and the staticApplicationError.getError(opts)all take the same input and build the same object - use the class form only when a direct reference reads better.Two input shapes. Free-form (
{ message, statusCode?, messageCode? }) covers one-off failures - most throw sites. Catalogued ({ error: TErrorDefinition }) raises a failure declared once at module scope, so its code, status, and default text cannot drift across the call sites that raise it.One message shape everywhere.
messageis either the historical string (with siblingmessageCode?/messageArgs?) or an object mirroringnormalized:{ text, code?, args? }. Both resolve to the samenormalized:typescriptgetError({ 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 } }); // cataloguedOn the catalogued form,
messageis a partial override -{ message: { args } }amends just the args and keeps the definition'stext/code.Precedence, most specific first.
code:message.code-> the definition'smessage.code->messageCode.args:message.args->messageArgs-> the definition'smessage.args.messageCodealways resolves to something.MessageCode.resolve()lower-cases it and falls back toMessageCode.DEFAULT('core.system_error') when none is given or it is empty -error.normalized.codeis neverundefined.normalizedis the single source, and there is no flat duplicate.{ text, code, args }-textdefaults tomessage,code/argsresolve per the precedence above.argsis always populated ({}when empty), so no consumer needs a null check. A client renders any error with one lookup:translate(error.normalized.code, error.normalized.args).messageCodeandmessageArgsare INPUTS only: there is noerror.messageCodefield, andextranever mirrorsmessageArgs. Passtransformto buildnormalizedyourself in place of the default.Any key the input does not declare rides into
extra. Attach whatever context your clients need -getError({ message, transaction: {...} })lands aterror.extra.transaction. Passingextraexplicitly works too, and the two merge with the explicit one winning.extracarries caller context ONLY; it isundefinedwhen there is nothing to carry.causereaches the nativeError.cause, notextra- wrap a lower-level failure withgetError({ message, cause: originalError })and every tool that reads.causesees it.erroris refused on the free-form branch (error?: never) precisely so this mistake fails at compile time:getError({ message, error: caughtError })does not compile - usecause.isApplicationError()checks shape, not class identity. There is oneApplicationError- it lives in@venizia/ignis-inversionso a browser application can raise and read the same errors the server does, andhelpersre-exports it.instanceofstill fails across a package boundary: inversion ships dual CJS+ESM builds, so one source class has two runtime constructors. Test the shape.
Options shared by both forms
| Option | Type | Description |
|---|---|---|
message | string | { text, code?, args? } | Required on the free-form branch; a partial override on the catalogued branch (definition supplies what's omitted) |
messageCode | string | Free-form only, sibling to a string message. Lowest precedence - message.code and the definition's message.code both win over it |
statusCode | number | Defaults to 400, or the definition's statusCode for the catalogued form |
messageArgs | Record<string, unknown> | Interpolation values. Reaches normalized.args - never extra. Lowest precedence, same rule as messageCode |
cause | unknown | The wrapped failure - reaches Error.cause. Use this to wrap a caught error; error is refused on the free-form branch for exactly this case |
extra | Record<string, unknown> | Explicit context, merged with any swept keys - and it wins on a clash |
transform | TErrorNormalizeTransformFn | Builds normalized in place of the default. Receives { message: TErrorNormalized, statusCode, extra? } - message is the default normalized being replaced, so s => ({ ...s.message, text: 'x' }) amends one field |
| anything else | unknown | Rides into extra under its own name. getError({ message, transaction }) lands at error.extra.transaction |
TIP
Spreading a definition now resolves identically to passing it as error: getError({ ...CategoryErrors.CREATE_DUPLICATE_NAME }) and getError({ error: CategoryErrors.CREATE_DUPLICATE_NAME }) build the same ApplicationError. A definition's message is { text, code, args? } - the same shape the free-form input accepts - so the spread degrades to nothing. Prefer { error: DEF } anyway; it reads as "raise this catalogued error" instead of "raise these loose fields."
Every shape and its output
Every row below is the real output, and the catalogued rows all use this definition:
const DEF = {
message: {
text: 'A category named "%{name}" already exists.',
code: 'server.commerce.category.duplicate',
args: { name: '?' },
},
statusCode: 409,
} as const satisfies TErrorDefinition;Free-form, flat - the historical shape, unchanged:
| Input | message | statusCode | normalized |
|---|---|---|---|
{ message: 'Broke' } | Broke | 400 | { text: 'Broke', code: 'core.system_error', args: {} } |
{ message: 'Broke', messageCode: 'a.b' } | Broke | 400 | { text: 'Broke', code: 'a.b', args: {} } |
{ message: 'Only %{n} left', messageArgs: { n: 2 } } | Only %{n} left | 400 | { text: 'Only %{n} left', code: 'core.system_error', args: { n: 2 } } |
{ message, messageCode: 'stock.low', messageArgs: { n: 2 }, statusCode: 409 } | Only %{n} left | 409 | { text: 'Only %{n} left', code: 'stock.low', args: { n: 2 } } |
Free-form, object - text is the only required field. Each row is identical to its flat twin above:
| Input | normalized |
|---|---|
{ message: { text: 'Broke' } } | { text: 'Broke', code: 'core.system_error', args: {} } |
{ message: { text: 'Broke', code: 'a.b' } } | { text: 'Broke', code: 'a.b', args: {} } |
{ message: { text: 'Only %{n} left', code: 'stock.low', args: { n: 2 } } } | { text: 'Only %{n} left', code: 'stock.low', args: { n: 2 } } |
Catalogued - omit a field and the definition supplies it:
| Input | message | statusCode | normalized.code | normalized.args |
|---|---|---|---|---|
{ error: DEF } | the definition's text | 409 | server.commerce.category.duplicate | { name: '?' } |
{ error: DEF, messageArgs: { name: 'Vé' } } | the definition's text | 409 | server.commerce.category.duplicate | { name: 'Vé' } |
{ error: DEF, message: { args: { name: 'Vé' } } } | the definition's text | 409 | server.commerce.category.duplicate | { name: 'Vé' } |
{ error: DEF, message: { text: 'Custom' } } | Custom | 409 | server.commerce.category.duplicate | { name: '?' } |
{ error: DEF, message: { code: 'override.code' } } | the definition's text | 409 | override.code | { name: '?' } |
{ error: DEF, message: 'Custom' } | Custom | 409 | server.commerce.category.duplicate | { name: '?' } |
{ error: DEF, statusCode: 410 } | the definition's text | 410 | server.commerce.category.duplicate | { name: '?' } |
{ ...DEF } (spread) | the definition's text | 409 | server.commerce.category.duplicate | { name: '?' } |
Context and cause:
| Input | Output |
|---|---|
{ message, extra: { categoryId: 42 } } | extra: { categoryId: 42 } |
{ message, userId: 7, transaction: { id: 12 } } | extra: { userId: 7, transaction: { id: 12 } } - swept |
{ message, userId: 7, extra: { userId: 9 } } | extra: { userId: 9 } - explicit wins |
{ message, cause: err } | Error.cause = err; extra stays undefined |
{ message, error: caughtError } | does not compile - use cause |
Transform - message in the snapshot IS the default being replaced:
getError({
message: { text: 'Only %{n} left', code: 'stock.low', args: { n: 2 } },
transform: snapshot => ({ ...snapshot.message, text: 'Chỉ còn 2 vé.' }),
});
// error.message -> 'Only %{n} left' (the raw text stays)
// error.normalized.text -> 'Chỉ còn 2 vé.'
// error.normalized.code -> 'stock.low'
// error.normalized.args -> { n: 2 }Precedence, most specific first - note a definition's message.code beats a flat messageCode:
| Resolves | Order |
|---|---|
normalized.code | message.code -> the definition's message.code -> messageCode -> MessageCode.DEFAULT |
normalized.args | message.args -> messageArgs -> the definition's message.args -> {} |
normalized.text | message.text (or a string message) -> the definition's message.text -> '' |
statusCode | statusCode -> the definition's statusCode -> 400 |
Common tasks
Throw a free-form error
The most common shape - a message and a status.
throw getError({
statusCode: HTTP.ResultCodes.RS_4.Conflict,
message: 'Username already exists',
});Recognize an already-shaped error in a catch block
import { isApplicationError } from '@venizia/ignis-helpers';
try {
await someOperation();
} catch (error) {
if (isApplicationError(error)) {
throw error; // already shaped - surface as-is
}
throw getError({
message: 'Operation failed',
statusCode: HTTP.ResultCodes.RS_5.InternalServerError,
});
}Catalog a reusable error
Declare it once; every call site raises it by reference instead of retyping the code and status.
import { ErrorScopes, getError, HTTP } from '@venizia/ignis-helpers';
import type { TErrorDefinition } from '@venizia/ignis-helpers';
const CategoryErrors = {
CREATE_DUPLICATE_NAME: {
message: {
text: 'A category named "%{name}" already exists.',
code: 'server.commerce.category.create.duplicate_name',
},
statusCode: HTTP.ResultCodes.RS_4.Conflict,
category: ErrorScopes.VALIDATION,
},
} as const satisfies Record<string, TErrorDefinition>;
throw getError({ error: CategoryErrors.CREATE_DUPLICATE_NAME, messageArgs: { name: 'Vé' } });
// Equivalent, using the catalogued form's partial override instead:
throw getError({ error: CategoryErrors.CREATE_DUPLICATE_NAME, message: { args: { name: 'Vé' } } });ErrorScopes groups a failure by intent - AUTH, VALIDATION, BUSINESS, SYSTEM, INTEGRATION - because statusCode cannot: a 409 is a business conflict in one place and a validation clash in another.
WARNING
category is catalog metadata only - it does not reach the error response. getError reads message.text, message.code and statusCode off a definition and ignores the rest. Use it to group and filter catalogs (ops dashboards, translator exports); do not expect a client to receive it.
Register catalog keys for messageCode autocomplete
Augment the module the file already imports from. IErrorKeyRegistry is declared in @venizia/ignis-inversion and re-exported by helpers; merging follows the re-export, so either name populates the same registry.
import type { TRegisterErrors } from '@venizia/ignis-helpers';
declare module '@venizia/ignis-helpers' {
interface IErrorKeyRegistry extends TRegisterErrors<typeof CategoryErrors> {}
}WARNING
TypeScript only treats declare module as an augmentation when the file imports that module. Name a module the file never imports and it silently becomes an inert ambient declaration - no error, no keys registered, autocomplete quietly empty.
Declare message.code as a literal string, not through MessageCode.build() - build() returns string, which would widen the registry to Record<string, true> and destroy the autocomplete.
Build a code outside a catalog
MessageCode.build() validates at import time instead of shipping a malformed code into production.
import { MessageCode } from '@venizia/ignis-helpers';
const NOT_FOUND = MessageCode.build({ parts: ['core', 'user', 'not_found'] });
// 'core.user.not_found' - throws if a segment isn't lower snake_case, or fewer than 2 partsRead the error response shape
AppErrorMiddleware (from @venizia/ignis) routes every thrown value into one of five shapes. All five carry message, statusCode, normalized and details; only an intentional error can carry extra. The code lives at normalized.code - there is no flat messageCode on the response.
| What was thrown | Status | normalized.code | message | extra |
|---|---|---|---|---|
ZodError (validation) | 422 | the first issue's params.code, else its raw Zod code | that issue's message; per-field list in details.cause | never |
| DB client error (SQLSTATE class 22/23/44) | 400 | core.system_error | a fixed, safe summary - never the driver's text | never |
| Transient DB conflict (40001/40P01) | 409 | database.conflict | a fixed retry message | never |
getError(...) - intentional | its own | its own | its own | when the throw site attached extra or unknown keys |
| Anything else | 500 | core.system_error | Internal Server Error in production; the raw message in development | never |
Only the intentional branch reports what the throw site wrote. The other four replace the message, because a driver error carries SQL, schema and constraint names - and normalized is built from the replacement, so it can never leak what message just scrubbed.
rootKey (e.g. new AppErrorMiddleware({ logger, rootKey: 'error' }).value()) nests any of these under that key.
The handler is fail-closed on environment: it exposes stack and cause in details only when NODE_ENV is one of local, debug, development, dev, sit (Environment.DEVELOPMENT_ENVS). Everything else - including alpha, staging, a typo, or an unset NODE_ENV - gets the sanitized shape:
{
"message": "Only %{available} left of %{variantId}.",
"statusCode": 409,
"normalized": {
"text": "Only %{available} left of %{variantId}.",
"code": "server.core.stock_reservation.reserve.unavailable",
"args": { "variantId": "V1", "available": 2 }
},
"extra": {
"details": { "locationId": "L9" }
},
"requestId": "abc-123-def",
"details": { "url": "http://localhost:3000/reservations", "path": "/reservations" }
}extra is absent entirely when the throw site attached no context of its own.
IMPORTANT
messageCode and extra.messageArgs are GONE from the response. They duplicated normalized.code and normalized.args. Read normalized - it is the only source. A client still reading either must migrate: translate(error.messageCode, error.extra?.messageArgs) becomes translate(error.normalized.code, error.normalized.args).
Note the two details: the inner one is context the throw site attached (it went through extra), the outer one is the middleware's own request info. They are unrelated despite the name.
NOTE
message and normalized.text are the same string unless a transform deliberately makes them differ - message stays the raw text the throw site wrote, normalized.text is what a client shows. Most errors never set transform, so most of the time they match.
Common status codes
| Scenario | Status | HTTP.ResultCodes path |
|---|---|---|
| Invalid input | 400 | RS_4.BadRequest |
| Missing/invalid auth | 401 | RS_4.Unauthorized |
| Insufficient permissions | 403 | RS_4.Forbidden |
| Resource not found | 404 | RS_4.NotFound |
| Duplicate resource | 409 | RS_4.Conflict |
| Server failure | 500 | RS_5.InternalServerError |
See also
- Controllers - throwing errors in route handlers
- Services - error handling in business logic
- Middlewares - the
AppErrorMiddlewarehandler - Environment -
Environment.DEVELOPMENT_ENVS, the error-detail boundary - Helpers Overview - all available helpers
Files:
packages/helpers/src/modules/error/index.ts- module barrelpackages/helpers/src/modules/error/types.ts-ErrorSchema,TErrorResponse(the RESPONSE schema, for OpenAPI)packages/inversion/src/modules/error/app-error.ts-ApplicationError,getError,isApplicationErrorpackages/inversion/src/modules/error/types.ts-TError,TErrorDefinition,TErrorNormalized,IErrorKeyRegistry,TRegisterErrorspackages/inversion/src/modules/error/definition.ts-ErrorScopes,TErrorScopepackages/inversion/src/modules/error/message-code.ts-MessageCode