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.

Find what you need ​

You want toGo to
Throw a one-off error with a status codeThrow a free-form error
Re-throw an error you already caught and shapedRecognize an already-shaped error in a catch block
Declare a reusable, i18n-ready error onceCatalog a reusable error
Get autocomplete on messageCodeRegister catalog keys for messageCode autocomplete
Build a message code outside a catalogBuild a code outside a catalog
See the exact JSON shape a client receivesRead the error response shape
Read or rehydrate a server error in a browser appConsume the error response from a browser client
Look up what a specific input producesEvery shape and its output
Confirm you're checking errors the safe wayHow it works - isApplicationError(), never instanceof

How it works ​

  • Three equivalent entry points. getError(opts), new ApplicationError(opts), and the static ApplicationError.getError(opts) all build the same object from the same input. Use the class form only when a direct reference reads better.

  • Two input shapes. Free-form covers one-off failures - most throw sites. Catalogued raises a failure declared once at module scope, so its code, status, and default text can't drift across the call sites that raise it.

    ShapeInput
    Free-form{ message, statusCode?, messageCode? }
    Catalogued{ error: TErrorDefinition }
  • 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 resolves most-specific-first. See the full precedence table below - a definition's own message.code beats a flat messageCode.

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

  • normalized is the single source - there's no flat duplicate. It's { text, code, args }. text defaults to message; code and args resolve per the precedence table.

  • args is always populated, so no consumer needs a null check. One lookup renders any error: translate(error.normalized.code, error.normalized.args).

  • messageCode and messageArgs are INPUTS only. There's no error.messageCode field, and extra never mirrors messageArgs. Pass transform to build normalized yourself in place of the default.

  • Any key the input doesn't declare rides into extra. Attach whatever context your clients need: getError({ message, transaction: {...} }) lands at error.extra.transaction. Passing extra explicitly works too - the two merge, and the explicit one wins on a clash.

  • extra carries caller context only. It's 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). This makes getError({ message, error: caughtError }) fail to compile, so the mistake is caught before runtime - use cause instead.

IMPORTANT

Use isApplicationError(), never instanceof ApplicationError. There is one ApplicationError class, defined in @venizia/ignis-inversion. helpers re-exports it, so a browser app can raise and read the same errors the server does. But instanceof still fails across a package boundary: inversion ships dual CJS+ESM builds, so one source class has two runtime constructors. isApplicationError() tests shape, not identity, so it works everywhere. The shape is an Error with a numeric statusCode and a normalized whose text is a string; a plain Error that only carries a statusCode, as an HTTP client's does, is not an ApplicationError.

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
logLevelerror | emerg | warn | info | debugThe level the error handler logs this at. Defaults to error; lower it for an expected failure (getError({ message, statusCode: 404, logLevel: 'warn' })), or raise it to emerg. Steers the server log only - never the response
anything elseunknownRides into extra under its own name. getError({ message, transaction }) lands at error.extra.transaction

WARNING

Spreading a definition is not the same as passing it as error. getError({ error: DEF }) consumes the whole definition under one known key. getError({ ...DEF }) hands its fields over individually. category and description are not in the consumed set, so they ride into error.extra under their own names. From there they reach the client response.

The message/statusCode half does resolve the same way, because a definition's message is { text, code, args? }, exactly the shape the free-form input accepts. The metadata half is what leaks. Always use { error: DEF }.

The consumed keys are error, message, messageCode, statusCode, messageArgs, cause, extra, transform, logLevel, and name. Anything else you pass to getError lands in error.extra. That is the feature the last table row describes, and the reason the spread form is a trap.

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: '?' }

The spread row matches only because this DEF carries nothing but message and statusCode. Add category or description to it and the spread moves them into extra, where the { error: DEF } form leaves them behind.

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. statusCode can't do this job: 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). Don't 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 own, with each %{key} filled from normalized.argswhen 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 - a driver error can carry SQL, schema, and constraint names. normalized is built from that 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 for Environment.DEVELOPMENT_ENVS: local, debug, development, dev, sit. Everything else - including alpha, staging, a typo, or an unset NODE_ENV - gets the sanitized shape:

json
{
  "message": "Only 2 left of V1.",
  "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're unrelated despite sharing a name.

NOTE

message is ready to show: the middleware fills each %{key} with String(args[key]) from normalized.args. normalized.text keeps the template, so a client that translates code + args formats it itself. A transform changes only normalized.text.

A key with no arg stays as written. Outside production, the middleware logs one warning naming the code and the missing keys. Error.message and the log line keep the template, so an arg value never leaves the redacted args map.

Store the filled text where a plain reader shows it ​

error.message stays the template, so a reader that only knows .message - a job queue's failure reason, a third-party log - shows %{key}. Fill it where you hand the error over:

typescript
const error = getError({
  message: { text: 'Only %{available} left of %{sku}.', code: 'stock.unavailable' },
  messageArgs: { available: 2, sku: 'TRA-01' },
  statusCode: 409,
});

error.toFilledText(); // 'Only 2 left of TRA-01.'
error.message; // 'Only %{available} left of %{sku}.' - unchanged

For a BullMQ processor, which stores job.failedReason = error.message:

typescript
const processor = async (job: Job) => {
  try {
    return await handle(job);
  } catch (error) {
    throw isApplicationError(error) ? new Error(error.toFilledText(), { cause: error }) : error;
  }
};

Fill only where you choose to. A filled text carries every arg value inline, outside the args map a log redacts by key. MessageTemplate.render({ text, args }) is the same fill, and also returns the keys it found no arg for.

WARNING

toFilledText() is a method, so it exists only on a real ApplicationError. An error body read from a response is a plain object: (body as ApplicationError).toFilledText() compiles, then throws toFilledText is not a function. Rebuild it first with fromError({ error: body }).toFilledText(), or fill the shape directly with MessageTemplate.render({ text: body.normalized?.text ?? '', args: body.normalized?.args }).text. A response message is already filled by the server, so a client rarely needs either.

For the same reason, an object literal typed ApplicationError - a test fixture, a mock - no longer compiles. Build it with getError(...).

Consume the error response from a browser client ​

The error layer lives in @venizia/ignis-inversion, not in helpers, precisely so a browser app can share it. Its only dependency is reflect-metadata; it ships dual CJS+ESM, and pulls in no server module. A frontend throws its own failures with the same getError the server uses, and reads the server's with the same field names.

Reading is all most clients need. normalized is on every error response:

  • getError always builds one.
  • A foreign error gets one synthesized.
  • A ZodError builds its own.

So there's no null-check and no parsing step: one lookup handles every case.

ts
const { error } = await response.json(); // `error` is the rootKey, if one is configured

translate(error.normalized.code, error.normalized.args);

Rehydrating is for the client that wants one catch block for both a server failure and a locally thrown one. fromError inverts the response the middleware emitted:

ts
import { fromError, isApplicationError } from '@venizia/ignis-inversion';

const { error } = await response.json();
throw fromError({ error }); // now an ApplicationError - isApplicationError() is true
Wire fieldWhere it lands
normalizednormalized, verbatim - text, code and args round-trip unchanged
messagenormalized.text, but only when normalized is missing entirely
statusCodestatusCode; 400 when the payload carries none
extraextra, verbatim
requestIdextra.requestId - it is the identifier a support ticket quotes, so it survives
detailsdropped - url and path the client already knows, and stack is the server's

Every field of TResponsedError is optional by design: a client parses what a gateway, a proxy or an older server actually sent. A body that is not an IGNIS error at all still yields an ApplicationError, degraded to MessageCode.DEFAULT and status 400. No call site branches on a parse failure.

TIP

Same rule here: prefer isApplicationError() over instanceof ApplicationError. See the callout above for why - a browser app bundles its own copy of the class, so instanceof fails across the boundary.

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: