Skip to content

Error Handling

Stratal provides a structured error handling system built around error codes, translatable messages, and automatic HTTP status mapping. Every error thrown in your application flows through a global handler that logs it, translates its message, and returns a consistent JSON response.

All errors returned by Stratal follow this structure:

{
"code": 1003,
"message": "Validation error",
"timestamp": "2026-02-26T12:00:00.000Z",
"metadata": {
"issues": [...]
}
}
FieldDescription
codeNumeric error code from the error code registry
messageTranslated, human-readable error message
timestampISO 8601 timestamp when the error occurred
metadataOptional structured data (validation issues, field names)
stackStack trace, included only in development

All custom errors extend the ApplicationError base class. It ties together a message key for i18n, a numeric error code, and optional metadata:

import { ApplicationError, ERROR_CODES } from 'stratal/errors'
export class InsufficientBalanceError extends ApplicationError {
constructor(balance: number, required: number) {
super(
'errors.insufficientBalance', // i18n message key
ERROR_CODES.BUSINESS.INSUFFICIENT_BALANCE, // numeric error code
{ balance, required }, // metadata for logging/interpolation
)
}
}

Throw it from any service or controller:

throw new InsufficientBalanceError(50, 100)

The global error handler catches it, translates the message key, and returns the proper HTTP response.

ParameterTypeDescription
i18nKeyMessageKeysA type-safe i18n message key (e.g., 'errors.userNotFound')
codenumberA numeric error code from ERROR_CODES
metadataRecord<string, unknown>Optional data for logging and message interpolation

Stratal organizes error codes by category, with each range mapping to an HTTP status code:

RangeCategoryHTTP Status
1000-1999Validation400 Bad Request
2000-2999Database500 (varies)
3000-3099Authentication401 Unauthorized
3100-3199Authorization403 Forbidden
4000-4099Resource404 Not Found
4100-4199Conflict409 Conflict
5000-5999Business Logic422 Unprocessable Entity
9000-9999System/Internal500 Internal Server Error

Access them through the ERROR_CODES object:

import { ERROR_CODES } from 'stratal/errors'
ERROR_CODES.VALIDATION.GENERIC // 1000
ERROR_CODES.VALIDATION.REQUIRED_FIELD // 1001
ERROR_CODES.VALIDATION.SCHEMA_VALIDATION // 1003
ERROR_CODES.AUTH.INVALID_CREDENTIALS // 3000
ERROR_CODES.AUTH.SESSION_EXPIRED // 3001
ERROR_CODES.AUTH.INVALID_TOKEN // 3003
ERROR_CODES.AUTHZ.FORBIDDEN // 3100
ERROR_CODES.AUTHZ.ACCESS_DENIED // 3101
ERROR_CODES.AUTHZ.INSUFFICIENT_PERMISSIONS // 3102
ERROR_CODES.RESOURCE.NOT_FOUND // 4000
ERROR_CODES.RESOURCE.CONFLICT // 4100
ERROR_CODES.RESOURCE.ALREADY_EXISTS // 4101
ERROR_CODES.DATABASE.RECORD_NOT_FOUND // 2001 -> 404
ERROR_CODES.DATABASE.UNIQUE_CONSTRAINT // 2002 -> 409
ERROR_CODES.SYSTEM.INTERNAL_ERROR // 9000

A few error codes within the database range have specific status overrides:

Error CodeHTTP StatusReason
DATABASE.RECORD_NOT_FOUND (2001)404Record does not exist
DATABASE.UNIQUE_CONSTRAINT (2002)409Duplicate entry

Define errors by extending ApplicationError:

import { ApplicationError, ERROR_CODES } from 'stratal/errors'
export class UserNotFoundError extends ApplicationError {
constructor(userId: string) {
super(
'errors.userNotFound',
ERROR_CODES.RESOURCE.NOT_FOUND,
{ userId },
)
}
}
export class DuplicateEmailError extends ApplicationError {
constructor(email: string) {
super(
'errors.duplicateEmail',
ERROR_CODES.RESOURCE.ALREADY_EXISTS,
{ field: 'email' },
)
}
}
export class SessionExpiredError extends ApplicationError {
constructor() {
super(
'errors.sessionExpired',
ERROR_CODES.AUTH.SESSION_EXPIRED,
)
}
}

Then throw them anywhere in your application:

@Transient()
export class UsersService {
async findById(id: string): Promise<User> {
const user = await this.repository.findById(id)
if (!user) {
throw new UserNotFoundError(id)
}
return user
}
async create(data: CreateUserDto): Promise<User> {
const existing = await this.repository.findByEmail(data.email)
if (existing) {
throw new DuplicateEmailError(data.email)
}
return this.repository.create(data)
}
}

You do not need to catch these in your controller. The global error handler catches them automatically and returns the right HTTP status code and response body.

Stratal registers a GlobalErrorHandler that intercepts every error in the application. It handles two categories:

Known errors (ApplicationError instances) are logged, translated, and serialized:

throw new UserNotFoundError('user-123')
// Produces:
{
"code": 4000,
"message": "User not found", // translated from 'errors.userNotFound'
"timestamp": "2026-02-26T12:00:00.000Z",
"metadata": {} // userId filtered out (internal)
}
// HTTP 404 (derived from code 4000)

Unknown errors (anything that is not an ApplicationError) are wrapped in an InternalError with code 9000:

throw new TypeError('Cannot read property of undefined')
// Produces:
{
"code": 9000,
"message": "Internal server error",
"timestamp": "2026-02-26T12:00:00.000Z"
}
// HTTP 500

The global error handler logs errors with different severity levels based on the error code range:

Code RangeSeverityExample
1000-1999INFOValidation errors
3000-4999WARNAuth, authorization, resource errors
5000-5999WARNBusiness logic errors
2000-2999ERRORDatabase errors
9000+ERRORSystem and internal errors

Not all metadata should be visible to API consumers. ApplicationError automatically filters metadata before including it in the response. Only these fields are exposed:

  • issues (validation error details)
  • fields (constraint violation field list)
  • field (single field identifier)

Everything else (like userId, reason, controllerName) is available in logs but stripped from the response. This means you can pass rich debugging context in metadata without worrying about leaking it.

// In your error class
throw new ApplicationError(
'errors.forbidden',
ERROR_CODES.AUTHZ.FORBIDDEN,
{
userId: 'user-123', // filtered out of response, available in logs
reason: 'IP blocked', // filtered out of response, available in logs
field: 'accessToken', // included in response
},
)

Stratal has built-in handling for Zod validation failures. When a @Route schema rejects a request, a SchemaValidationError is created automatically. You do not need to write any error handling code for this.

The validation error response includes an issues array:

{
"code": 1003,
"message": "Validation error",
"timestamp": "2026-02-26T12:00:00.000Z",
"metadata": {
"issues": [
{
"path": "email",
"message": "Invalid email address",
"code": "invalid_format"
}
]
}
}

See the Validation guide for more on schema validation.

When the Internationalization module is configured, error messages are automatically translated based on the request locale. The message parameter on ApplicationError is treated as an i18n message key, and metadata values can be used for interpolation:

// Message key: 'errors.insufficientBalance'
// Translation: 'Your balance of {balance} is less than the required {required}'
throw new InsufficientBalanceError(50, 100)
// Response:
{
"code": 5001,
"message": "Your balance of 50 is less than the required 100"
}

If no translation is found, the raw message key is returned as-is.

You can throw ApplicationError subclasses from controllers, services, guards, or middleware. The global error handler catches them at every level:

@Controller('/api/users')
export class UsersController implements IController {
constructor(
@inject(UsersService) private readonly usersService: UsersService,
) {}
@Route({
params: z.object({ id: z.string().uuid() }),
response: userResponseSchema,
})
async show(ctx: RouterContext) {
const id = ctx.param('id')
// If findById throws UserNotFoundError, the global handler returns 404
const user = await this.usersService.findById(id)
return ctx.json(user)
}
}

Checking if an error is an ApplicationError

Section titled “Checking if an error is an ApplicationError”

Use the isApplicationError type guard when you need to distinguish framework errors from unexpected ones:

import { isApplicationError } from 'stratal/errors'
try {
await someOperation()
} catch (error) {
if (isApplicationError(error)) {
// error.code, error.message, error.metadata are available
console.log('Known error:', error.code)
} else {
// Unexpected error
console.error('Unexpected:', error)
}
}
  • Validation for automatic request validation with Zod.
  • Guards to learn about access control and how guard failures produce errors.
  • Internationalization for translating error messages across locales.