Unicorn provides structured error classes and a layered error-handling strategy. Import everything from @/core/lib/logger:
import { AppError, HttpError, ValidationError, DatabaseError } from '@/core/lib/logger';Base error class with structured metadata. All other error classes extend it.
throw new AppError('User not found', {
code: 'ERR_USER_NOT_FOUND',
statusCode: 404,
metadata: { userId: '123' },
isOperational: true,
cause: originalError,
});| Property | Type | Default | Description |
|---|---|---|---|
code |
string |
'ERR_UNKNOWN' |
Machine-readable error code |
statusCode |
number |
500 |
HTTP status hint |
metadata |
Record<string, unknown> |
{} |
Arbitrary context for debugging |
isOperational |
boolean |
true |
true = expected failure, false = bug |
timestamp |
string |
β | ISO timestamp of when the error was created |
cause |
Error |
β | Native ES2022 error cause |
All AppError fields are serialized by the logger and appear in Sentry issue context β enabling filtering by error code and distinguishing operational errors from bugs.
For HTTP API call failures. Always operational.
throw new HttpError('Discord API rate limited', 429, {
code: 'ERR_RATE_LIMITED',
metadata: { retryAfter: 5000 },
});For input validation failures. Always 400, always operational. The second argument is a fields record mapping field names to arrays of error messages.
throw new ValidationError(
'Invalid input',
{
username: ['Required', 'Must be at least 3 characters'],
email: ['Invalid format'],
},
);For database failures. Defaults to non-operational (statusCode 503).
throw new DatabaseError('Connection timeout', {
code: 'ERR_DB_TIMEOUT',
metadata: { host: 'db.example.com' },
cause: originalError,
});Framework error codes used internally:
| Code | Source | Description |
|---|---|---|
ERR_CONFIG_PARSE |
parseConfig() |
Configuration validation failed |
ERR_SPARK_LOAD |
loadSparks() |
Failed to load a spark file |
ERR_COMMAND_GROUP_EMPTY |
defineCommandGroup() |
No subcommands or groups provided |
When writing sparks, use descriptive codes prefixed with ERR_:
new AppError('Queue is full', {
code: 'ERR_QUEUE_FULL',
metadata: { queueSize: 100, maxSize: 100 },
});Unicorn uses a layered approach:
Configuration parsing, spark loading, and Discord login throw on failure. The process cannot function without these succeeding.
// These throw AppError on failure β intentionally unhandled
const config = parseConfig(appConfig);
await loadSparks(client, sparksDir);
await client.login(config.discord.apiToken);Spark actions use attempt() for Result-based error handling. Errors are logged but never terminate the process.
import { attempt } from '@/core/lib/attempt';
action: async (interaction) => {
const result = await attempt(() => fetchUserData(interaction.user.id));
if (result.isErr()) {
interaction.client.logger.error({ err: result.error, user: interaction.user.id }, 'Failed to fetch user');
await interaction.reply({ content: 'Something went wrong.', flags: MessageFlags.Ephemeral });
return;
}
// use result.data
}Tip
The framework's execute() wrapper catches any unhandled errors from your action as a safety net. But you should still use attempt() for all fallible operations β it lets you make decisions about how to respond to the user.
Each cleanup step runs independently. A failure in one step doesn't prevent the others from running.
- Always use
attempt()for fallible operations β never let promises go unhandled - Use
{ err: error }or{ error }key when logging errors β both trigger the serializer - Use AppError with codes for domain errors β searchable in Sentry, filterable
- Use
metadatafor context, not the message β structured data > interpolated strings - Use
causewhen wrapping β preserves the full error chain isOperational: true(default) for expected failures (rate limits, not found, validation)isOperational: falsefor bugs or system failures that should trigger alerts- Don't create subclasses unless adding new structured fields β use
codeinstead - Startup code should throw (fast-fail) β runtime spark code should log and recover
// Good β structured context in metadata
const result = await attempt(() => api.getUser(userId));
if (result.isErr()) {
throw new AppError('Failed to fetch user', {
code: 'ERR_USER_FETCH',
metadata: { userId },
cause: result.error,
});
}
// Bad β context baked into message string
throw new Error(`Failed to fetch user ${userId}: ${error.message}`);