Skip to content

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

typescript
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 static ApplicationError.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. message is either the historical string (with sibling messageCode?/messageArgs?) or an object mirroring normalized: { text, code?, args? }. Both resolve to the same normalized:

    typescript
    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 } });                              // catalogued

    On the catalogued form, message is a partial override - { message: { args } } amends just the args and keeps the definition's text/code.

  • Precedence, most specific first. code: message.code -> the definition's message.code -> messageCode. args: message.args -> messageArgs -> the definition's message.args.

  • messageCode always resolves to something. MessageCode.resolve() lower-cases it and falls back to MessageCode.DEFAULT ('core.system_error') when none is given or it is empty - error.normalized.code is never undefined.

  • normalized is the single source, and there is no flat duplicate. { text, code, args } - text defaults to message, code/args resolve per the precedence above. args is 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). messageCode and messageArgs are INPUTS only: there is no error.messageCode field, and extra never mirrors messageArgs. Pass transform to build normalized yourself 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 at error.extra.transaction. Passing extra explicitly works too, and the two merge with the explicit one winning. extra carries caller context ONLY; it is undefined when there is nothing to carry.

  • cause reaches the native Error.cause, not extra - wrap a lower-level failure with getError({ message, cause: originalError }) and every tool that reads .cause sees it. error is refused on the free-form branch (error?: never) precisely so this mistake fails at compile time: getError({ message, error: caughtError }) does not compile - use cause.

  • isApplicationError() checks shape, not class identity. There is one ApplicationError - it lives in @venizia/ignis-inversion so a browser application can raise and read the same errors the server does, and helpers re-exports it. instanceof still 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

OptionTypeDescription
messagestring | { text, code?, args? }Required on the free-form branch; a partial override on the catalogued branch (definition supplies what's omitted)
messageCodestringFree-form only, sibling to a string message. Lowest precedence - message.code and the definition's message.code both win over it
statusCodenumberDefaults to 400, or the definition's statusCode for the catalogued form
messageArgsRecord<string, unknown>Interpolation values. Reaches normalized.args - never extra. Lowest precedence, same rule as messageCode
causeunknownThe wrapped failure - reaches Error.cause. Use this to wrap a caught error; error is refused on the free-form branch for exactly this case
extraRecord<string, unknown>Explicit context, merged with any swept keys - and it wins on a clash
transformTErrorNormalizeTransformFnBuilds 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 elseunknownRides 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:

typescript
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:

InputmessagestatusCodenormalized
{ message: 'Broke' }Broke400{ text: 'Broke', code: 'core.system_error', args: {} }
{ message: 'Broke', messageCode: 'a.b' }Broke400{ text: 'Broke', code: 'a.b', args: {} }
{ message: 'Only %{n} left', messageArgs: { n: 2 } }Only %{n} left400{ text: 'Only %{n} left', code: 'core.system_error', args: { n: 2 } }
{ message, messageCode: 'stock.low', messageArgs: { n: 2 }, statusCode: 409 }Only %{n} left409{ 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:

Inputnormalized
{ 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:

InputmessagestatusCodenormalized.codenormalized.args
{ error: DEF }the definition's text409server.commerce.category.duplicate{ name: '?' }
{ error: DEF, messageArgs: { name: 'Vé' } }the definition's text409server.commerce.category.duplicate{ name: 'Vé' }
{ error: DEF, message: { args: { name: 'Vé' } } }the definition's text409server.commerce.category.duplicate{ name: 'Vé' }
{ error: DEF, message: { text: 'Custom' } }Custom409server.commerce.category.duplicate{ name: '?' }
{ error: DEF, message: { code: 'override.code' } }the definition's text409override.code{ name: '?' }
{ error: DEF, message: 'Custom' }Custom409server.commerce.category.duplicate{ name: '?' }
{ error: DEF, statusCode: 410 }the definition's text410server.commerce.category.duplicate{ name: '?' }
{ ...DEF } (spread)the definition's text409server.commerce.category.duplicate{ name: '?' }

Context and cause:

InputOutput
{ 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:

typescript
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:

ResolvesOrder
normalized.codemessage.code -> the definition's message.code -> messageCode -> MessageCode.DEFAULT
normalized.argsmessage.args -> messageArgs -> the definition's message.args -> {}
normalized.textmessage.text (or a string message) -> the definition's message.text -> ''
statusCodestatusCode -> the definition's statusCode -> 400

Common tasks

Throw a free-form error

The most common shape - a message and a status.

typescript
throw getError({
  statusCode: HTTP.ResultCodes.RS_4.Conflict,
  message: 'Username already exists',
});

Recognize an already-shaped error in a catch block

typescript
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.

typescript
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.

typescript
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.

typescript
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 parts

Read 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 thrownStatusnormalized.codemessageextra
ZodError (validation)422the first issue's params.code, else its raw Zod codethat issue's message; per-field list in details.causenever
DB client error (SQLSTATE class 22/23/44)400core.system_errora fixed, safe summary - never the driver's textnever
Transient DB conflict (40001/40P01)409database.conflicta fixed retry messagenever
getError(...) - intentionalits ownits ownits ownwhen the throw site attached extra or unknown keys
Anything else500core.system_errorInternal Server Error in production; the raw message in developmentnever

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:

json
{
  "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

ScenarioStatusHTTP.ResultCodes path
Invalid input400RS_4.BadRequest
Missing/invalid auth401RS_4.Unauthorized
Insufficient permissions403RS_4.Forbidden
Resource not found404RS_4.NotFound
Duplicate resource409RS_4.Conflict
Server failure500RS_5.InternalServerError

See also

Files: