Merge branch 'feat/mana-core'

This commit is contained in:
Wuesteon 2025-11-25 18:57:32 +01:00
commit 28d167a978
112 changed files with 34765 additions and 548 deletions

View file

@ -30,18 +30,18 @@ const DEFAULT_STORAGE_KEYS: StorageKeys = {
};
/**
* Default API endpoints
* Default API endpoints - Updated for Mana Core Auth
*/
const DEFAULT_ENDPOINTS: AuthEndpoints = {
signIn: '/auth/signin',
signUp: '/auth/signup',
signOut: '/auth/logout',
refresh: '/auth/refresh',
validate: '/auth/validate',
forgotPassword: '/auth/forgot-password',
googleSignIn: '/auth/google-signin',
appleSignIn: '/auth/apple-signin',
credits: '/auth/credits',
signIn: '/api/v1/auth/login',
signUp: '/api/v1/auth/register',
signOut: '/api/v1/auth/logout',
refresh: '/api/v1/auth/refresh',
validate: '/api/v1/auth/validate',
forgotPassword: '/api/v1/auth/forgot-password',
googleSignIn: '/api/v1/auth/google-signin',
appleSignIn: '/api/v1/auth/apple-signin',
credits: '/api/v1/credits/balance',
};
/**
@ -68,7 +68,12 @@ export function createAuthService(config: AuthServiceConfig) {
const response = await fetch(`${baseUrl}${endpoints.signIn}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, deviceInfo }),
body: JSON.stringify({
email,
password,
deviceId: deviceInfo?.deviceId,
deviceName: deviceInfo?.deviceName
}),
});
if (!response.ok) {
@ -76,7 +81,9 @@ export function createAuthService(config: AuthServiceConfig) {
return service.handleAuthError(response.status, errorData);
}
const { appToken, refreshToken } = await response.json();
const data = await response.json();
const appToken = data.accessToken; // Mana Core Auth uses 'accessToken'
const refreshToken = data.refreshToken;
await Promise.all([
storage.setItem(storageKeys.APP_TOKEN, appToken),
@ -106,7 +113,7 @@ export function createAuthService(config: AuthServiceConfig) {
const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, deviceInfo }),
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
@ -123,22 +130,9 @@ export function createAuthService(config: AuthServiceConfig) {
const responseData = await response.json();
// Check if email verification is required
if (responseData.confirmationRequired) {
return { success: true, needsVerification: true };
}
const { appToken, refreshToken } = responseData;
if (appToken && refreshToken) {
await Promise.all([
storage.setItem(storageKeys.APP_TOKEN, appToken),
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
storage.setItem(storageKeys.USER_EMAIL, email),
]);
}
return { success: true };
// Mana Core Auth returns user data immediately on registration
// User needs to sign in separately to get tokens
return { success: true, needsVerification: false };
} catch (error) {
console.error('Error signing up:', error);
return {
@ -219,7 +213,7 @@ export function createAuthService(config: AuthServiceConfig) {
const response = await fetch(`${baseUrl}${endpoints.refresh}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
body: JSON.stringify({ refreshToken: currentRefreshToken }),
});
if (!response.ok) {
@ -232,7 +226,9 @@ export function createAuthService(config: AuthServiceConfig) {
throw new Error(errorData.message || 'Failed to refresh tokens');
}
const { appToken, refreshToken } = await response.json();
const data = await response.json();
const appToken = data.accessToken; // Mana Core Auth uses 'accessToken'
const refreshToken = data.refreshToken;
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh - missing tokens');
@ -431,9 +427,9 @@ export function createAuthService(config: AuthServiceConfig) {
const data = await response.json();
return {
credits: data.credits || 0,
maxCreditLimit: data.max_credit_limit || 1000,
userId: data.id || 'unknown',
credits: (data.balance || 0) + (data.freeCreditsRemaining || 0),
maxCreditLimit: data.maxCreditLimit || 1000,
userId: data.userId || 'unknown',
};
} catch (error) {
console.error('Error fetching user credits:', error);

View file

@ -0,0 +1,30 @@
{
"name": "@manacore/shared-errors",
"version": "0.1.0",
"private": true,
"description": "Go-like error handling system for Manacore backends",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./nestjs": "./src/nestjs/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"peerDependencies": {
"@nestjs/common": ">=10.0.0"
},
"peerDependenciesMeta": {
"@nestjs/common": {
"optional": true
}
},
"devDependencies": {
"@nestjs/common": "^11.0.17",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,179 @@
import {
ErrorCode,
ERROR_CODE_TO_HTTP_STATUS,
ERROR_CODE_RETRYABLE,
} from '../types/error-codes';
/**
* Additional context that can be attached to errors.
*/
export interface ErrorContext {
[key: string]: unknown;
}
/**
* Options for creating an AppError.
*/
export interface AppErrorOptions {
code: ErrorCode;
message: string;
cause?: Error | AppError;
context?: ErrorContext;
httpStatus?: number;
retryable?: boolean;
}
/**
* Base error class for all application errors.
*
* Follows Go-like error handling principles:
* - Errors are values, not exceptions
* - Support for error wrapping with context
* - Type-safe error checking
*
* @example
* ```typescript
* // Create a basic error
* const error = new AppError({
* code: ErrorCode.VALIDATION_FAILED,
* message: 'Invalid email format',
* });
*
* // Wrap an error with context (Go-like)
* const wrapped = error.wrap('validating user input');
* // Message becomes: "validating user input: Invalid email format"
*
* // Check error codes (like Go's errors.Is)
* if (error.hasCode(ErrorCode.VALIDATION_FAILED)) {
* // Handle validation error
* }
* ```
*/
export class AppError extends Error {
/** Standardized error code */
readonly code: ErrorCode;
/** HTTP status code for API responses */
readonly httpStatus: number;
/** Whether the operation can be retried */
readonly retryable: boolean;
/** Original error that caused this error (for wrapping) */
readonly cause?: Error | AppError;
/** Additional context information */
readonly context: ErrorContext;
/** Timestamp when error was created */
readonly timestamp: string;
constructor(options: AppErrorOptions) {
super(options.message);
this.name = 'AppError';
this.code = options.code;
this.cause = options.cause;
this.context = options.context ?? {};
this.timestamp = new Date().toISOString();
// Use provided values or defaults from mappings
this.httpStatus =
options.httpStatus ?? ERROR_CODE_TO_HTTP_STATUS[options.code];
this.retryable = options.retryable ?? ERROR_CODE_RETRYABLE[options.code];
// Capture stack trace
Error.captureStackTrace(this, this.constructor);
}
/**
* Create a wrapped error with additional context.
* Similar to Go's `fmt.Errorf("context: %w", err)`.
*
* @param contextMessage - Description of the operation that failed
* @param additionalContext - Extra context data to include
* @returns A new AppError with the original as its cause
*
* @example
* ```typescript
* const wrapped = originalError.wrap('fetching user data');
* // Message: "fetching user data: original message"
* ```
*/
wrap(contextMessage: string, additionalContext?: ErrorContext): AppError {
return new AppError({
code: this.code,
message: `${contextMessage}: ${this.message}`,
cause: this,
context: { ...this.context, ...additionalContext },
httpStatus: this.httpStatus,
retryable: this.retryable,
});
}
/**
* Get the root cause of the error chain.
* Traverses the cause chain to find the original error.
*/
rootCause(): Error {
let current: Error = this;
while (current instanceof AppError && current.cause) {
current = current.cause;
}
return current;
}
/**
* Check if this error or any in the chain has the given code.
* Similar to Go's `errors.Is()`.
*
* @param code - The error code to check for
* @returns true if this error or any cause has the given code
*
* @example
* ```typescript
* if (error.hasCode(ErrorCode.INSUFFICIENT_CREDITS)) {
* // Show upgrade prompt
* }
* ```
*/
hasCode(code: ErrorCode): boolean {
let current: Error | undefined = this;
while (current) {
if (current instanceof AppError && current.code === code) {
return true;
}
current = current instanceof AppError ? current.cause : undefined;
}
return false;
}
/**
* Convert to JSON for API responses.
* Excludes stack traces and internal details.
*/
toJSON(): Record<string, unknown> {
return {
code: this.code,
message: this.message,
httpStatus: this.httpStatus,
retryable: this.retryable,
timestamp: this.timestamp,
...(Object.keys(this.context).length > 0 && { details: this.context }),
};
}
/**
* Convert to full JSON including stack and cause (for logging).
* Use this for server-side logging, not client responses.
*/
toFullJSON(): Record<string, unknown> {
return {
...this.toJSON(),
stack: this.stack,
cause:
this.cause instanceof AppError
? this.cause.toFullJSON()
: this.cause?.message,
};
}
}

View file

@ -0,0 +1,79 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type AuthErrorCode =
| ErrorCode.AUTHENTICATION_REQUIRED
| ErrorCode.INVALID_TOKEN
| ErrorCode.TOKEN_EXPIRED
| ErrorCode.PERMISSION_DENIED
| ErrorCode.RESOURCE_NOT_OWNED;
/**
* Error for authentication and authorization failures.
* HTTP Status: 401 (auth) or 403 (authorization)
*
* @example
* ```typescript
* // Authentication errors (401)
* return err(AuthError.unauthorized());
* return err(AuthError.invalidToken('Token has been revoked'));
* return err(AuthError.tokenExpired());
*
* // Authorization errors (403)
* return err(AuthError.forbidden('Admin access required'));
* return err(AuthError.notOwned('Story', storyId));
* ```
*/
export class AuthError extends AppError {
constructor(code: AuthErrorCode, message: string, context?: ErrorContext) {
super({ code, message, context });
this.name = 'AuthError';
}
/**
* Create an error for missing authentication.
* HTTP 401 Unauthorized
*/
static unauthorized(message = 'Authentication required'): AuthError {
return new AuthError(ErrorCode.AUTHENTICATION_REQUIRED, message);
}
/**
* Create an error for an invalid token.
* HTTP 401 Unauthorized
*/
static invalidToken(message = 'Invalid or malformed token'): AuthError {
return new AuthError(ErrorCode.INVALID_TOKEN, message);
}
/**
* Create an error for an expired token.
* HTTP 401 Unauthorized
*/
static tokenExpired(message = 'Token has expired'): AuthError {
return new AuthError(ErrorCode.TOKEN_EXPIRED, message);
}
/**
* Create an error for insufficient permissions.
* HTTP 403 Forbidden
*/
static forbidden(message = 'Permission denied'): AuthError {
return new AuthError(ErrorCode.PERMISSION_DENIED, message);
}
/**
* Create an error when a user tries to access a resource they don't own.
* HTTP 403 Forbidden
*
* @param resourceType - Type of resource (e.g., 'Story', 'Character')
* @param resourceId - ID of the resource
*/
static notOwned(resourceType: string, resourceId: string): AuthError {
return new AuthError(
ErrorCode.RESOURCE_NOT_OWNED,
`${resourceType} does not belong to you`,
{ resourceType, resourceId }
);
}
}

View file

@ -0,0 +1,35 @@
import { ErrorCode } from '../types/error-codes';
import { AppError } from './app-error';
/**
* Error for insufficient credits/mana.
* HTTP Status: 402 Payment Required
*
* @example
* ```typescript
* return err(new CreditError(100, 50, 'story_generation'));
* // Message: "Insufficient credits. Required: 100, Available: 50"
* ```
*/
export class CreditError extends AppError {
/** Credits required for the operation */
readonly requiredCredits: number;
/** Credits currently available */
readonly availableCredits: number;
constructor(
requiredCredits: number,
availableCredits: number,
operation?: string
) {
super({
code: ErrorCode.INSUFFICIENT_CREDITS,
message: `Insufficient credits. Required: ${requiredCredits}, Available: ${availableCredits}`,
context: { requiredCredits, availableCredits, operation },
});
this.name = 'CreditError';
this.requiredCredits = requiredCredits;
this.availableCredits = availableCredits;
}
}

View file

@ -0,0 +1,54 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type DatabaseErrorCode = ErrorCode.DATABASE_ERROR | ErrorCode.CONSTRAINT_VIOLATION;
/**
* Error for database-level failures.
* HTTP Status: 500 (database), 409 (constraint violation)
*
* @example
* ```typescript
* // Constraint violation (e.g., unique constraint)
* return err(DatabaseError.constraintViolation('email', 'Email already exists'));
*
* // Generic database error
* return err(DatabaseError.queryFailed('Failed to fetch user data', originalError));
* ```
*/
export class DatabaseError extends AppError {
constructor(
code: DatabaseErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'DatabaseError';
}
/**
* Create a constraint violation error (e.g., unique constraint).
*
* @param field - The field that violated the constraint
* @param message - Description of the violation
*/
static constraintViolation(field: string, message: string): DatabaseError {
return new DatabaseError(
ErrorCode.CONSTRAINT_VIOLATION,
message,
undefined,
{ field }
);
}
/**
* Create a generic database query error.
*
* @param message - Description of what went wrong
* @param cause - Original error if available
*/
static queryFailed(message: string, cause?: Error): DatabaseError {
return new DatabaseError(ErrorCode.DATABASE_ERROR, message, cause);
}
}

View file

@ -0,0 +1,9 @@
export { AppError, type ErrorContext, type AppErrorOptions } from './app-error';
export { ValidationError } from './validation-error';
export { AuthError } from './auth-error';
export { NotFoundError } from './not-found-error';
export { CreditError } from './credit-error';
export { ServiceError } from './service-error';
export { RateLimitError } from './rate-limit-error';
export { NetworkError } from './network-error';
export { DatabaseError } from './database-error';

View file

@ -0,0 +1,63 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type NetworkErrorCode =
| ErrorCode.NETWORK_ERROR
| ErrorCode.TIMEOUT
| ErrorCode.CONNECTION_REFUSED;
/**
* Error for network-level failures (timeouts, connection issues, etc.).
* HTTP Status: 502 (gateway), 503 (connection refused), 504 (timeout)
*
* @example
* ```typescript
* // Timeout
* return err(NetworkError.timeout('Fetching user profile'));
*
* // Connection refused
* return err(NetworkError.connectionRefused('Database'));
*
* // Generic network error
* return err(new NetworkError(ErrorCode.NETWORK_ERROR, 'DNS resolution failed'));
* ```
*/
export class NetworkError extends AppError {
constructor(
code: NetworkErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'NetworkError';
}
/**
* Create a timeout error.
*
* @param operation - Description of the operation that timed out
*/
static timeout(operation: string): NetworkError {
return new NetworkError(
ErrorCode.TIMEOUT,
`Operation timed out: ${operation}`,
undefined,
{ operation }
);
}
/**
* Create a connection refused error.
*
* @param service - Name of the service that refused connection
*/
static connectionRefused(service: string): NetworkError {
return new NetworkError(
ErrorCode.CONNECTION_REFUSED,
`Connection refused: ${service}`,
undefined,
{ service }
);
}
}

View file

@ -0,0 +1,45 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
/**
* Error for when a requested resource is not found.
* HTTP Status: 404 Not Found
*
* @example
* ```typescript
* // Generic resource not found
* return err(new NotFoundError('User', userId));
*
* // Using factory methods
* return err(NotFoundError.user(userId));
* return err(NotFoundError.resource('Story', storyId));
* ```
*/
export class NotFoundError extends AppError {
constructor(
resourceType: string,
identifier: string,
context?: ErrorContext
) {
super({
code: ErrorCode.RESOURCE_NOT_FOUND,
message: `${resourceType} not found: ${identifier}`,
context: { resourceType, identifier, ...context },
});
this.name = 'NotFoundError';
}
/**
* Create a not found error for a user.
*/
static user(userId: string): NotFoundError {
return new NotFoundError('User', userId);
}
/**
* Create a not found error for any resource type.
*/
static resource(resourceType: string, identifier: string): NotFoundError {
return new NotFoundError(resourceType, identifier);
}
}

View file

@ -0,0 +1,31 @@
import { ErrorCode } from '../types/error-codes';
import { AppError } from './app-error';
/**
* Error for rate limiting.
* HTTP Status: 429 Too Many Requests
*
* @example
* ```typescript
* // Basic rate limit error
* return err(new RateLimitError());
*
* // With retry-after information
* return err(new RateLimitError('Too many requests', 60));
* // Client should wait 60 seconds before retrying
* ```
*/
export class RateLimitError extends AppError {
/** Seconds to wait before retrying (if known) */
readonly retryAfter?: number;
constructor(message = 'Rate limit exceeded', retryAfter?: number) {
super({
code: ErrorCode.RATE_LIMIT_EXCEEDED,
message,
context: retryAfter ? { retryAfter } : {},
});
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
}
}

View file

@ -0,0 +1,103 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type ServiceErrorCode =
| ErrorCode.INTERNAL_ERROR
| ErrorCode.SERVICE_UNAVAILABLE
| ErrorCode.GENERATION_FAILED
| ErrorCode.EXTERNAL_SERVICE_ERROR;
/**
* Error for service-level failures (internal errors, external API failures, etc.).
* HTTP Status: 500 (internal), 502 (external), 503 (unavailable)
*
* @example
* ```typescript
* // AI generation failed
* return err(ServiceError.generationFailed('OpenAI', 'Rate limit exceeded', originalError));
*
* // External service unavailable
* return err(ServiceError.unavailable('Payment Service'));
*
* // External API error
* return err(ServiceError.externalError('Stripe', 'Card declined'));
*
* // Internal error
* return err(ServiceError.internal('Failed to process request'));
* ```
*/
export class ServiceError extends AppError {
constructor(
code: ServiceErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'ServiceError';
}
/**
* Create an error for AI/content generation failures.
*
* @param service - Name of the service (e.g., 'OpenAI', 'Azure OpenAI')
* @param reason - Why the generation failed
* @param cause - Original error if available
*/
static generationFailed(
service: string,
reason: string,
cause?: Error
): ServiceError {
return new ServiceError(
ErrorCode.GENERATION_FAILED,
`${service} generation failed: ${reason}`,
cause,
{ service }
);
}
/**
* Create an error for a service that is temporarily unavailable.
*
* @param service - Name of the unavailable service
*/
static unavailable(service: string): ServiceError {
return new ServiceError(
ErrorCode.SERVICE_UNAVAILABLE,
`${service} is temporarily unavailable`,
undefined,
{ service }
);
}
/**
* Create an error for external API failures.
*
* @param service - Name of the external service
* @param message - Error message or description
* @param cause - Original error if available
*/
static externalError(
service: string,
message: string,
cause?: Error
): ServiceError {
return new ServiceError(
ErrorCode.EXTERNAL_SERVICE_ERROR,
`${service} error: ${message}`,
cause,
{ service }
);
}
/**
* Create an internal server error.
*
* @param message - Description of what went wrong
* @param cause - Original error if available
*/
static internal(message: string, cause?: Error): ServiceError {
return new ServiceError(ErrorCode.INTERNAL_ERROR, message, cause);
}
}

View file

@ -0,0 +1,59 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
/**
* Error for validation failures (invalid input, missing fields, etc.).
* HTTP Status: 400 Bad Request
*
* @example
* ```typescript
* // Using factory methods
* return err(ValidationError.invalidInput('email', 'must be a valid email address'));
* return err(ValidationError.missingField('password'));
*
* // Direct construction
* return err(new ValidationError('Age must be a positive number', { field: 'age' }));
* ```
*/
export class ValidationError extends AppError {
constructor(message: string, context?: ErrorContext) {
super({
code: ErrorCode.VALIDATION_FAILED,
message,
context,
});
this.name = 'ValidationError';
}
/**
* Create a validation error for an invalid field value.
*
* @param field - The field name that failed validation
* @param reason - Why the validation failed
*/
static invalidInput(field: string, reason: string): ValidationError {
return new ValidationError(`Invalid ${field}: ${reason}`, { field, reason });
}
/**
* Create a validation error for a missing required field.
*
* @param field - The field name that is missing
*/
static missingField(field: string): ValidationError {
return new ValidationError(`Missing required field: ${field}`, { field });
}
/**
* Create a validation error for an invalid format.
*
* @param field - The field name with invalid format
* @param expectedFormat - Description of the expected format
*/
static invalidFormat(field: string, expectedFormat: string): ValidationError {
return new ValidationError(
`Invalid format for ${field}: expected ${expectedFormat}`,
{ field, expectedFormat }
);
}
}

View file

@ -0,0 +1 @@
export * from './type-guards';

View file

@ -0,0 +1,158 @@
import { AppError } from '../errors/app-error';
import { ValidationError } from '../errors/validation-error';
import { AuthError } from '../errors/auth-error';
import { NotFoundError } from '../errors/not-found-error';
import { CreditError } from '../errors/credit-error';
import { ServiceError } from '../errors/service-error';
import { RateLimitError } from '../errors/rate-limit-error';
import { NetworkError } from '../errors/network-error';
import { DatabaseError } from '../errors/database-error';
import { ErrorCode } from '../types/error-codes';
/**
* Check if error is an AppError.
* Similar to Go's `errors.As()`.
*
* @example
* ```typescript
* if (isAppError(error)) {
* console.log(error.code); // TypeScript knows error is AppError
* }
* ```
*/
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
/**
* Check if error is a ValidationError.
*/
export function isValidationError(error: unknown): error is ValidationError {
return error instanceof ValidationError;
}
/**
* Check if error is an AuthError.
*/
export function isAuthError(error: unknown): error is AuthError {
return error instanceof AuthError;
}
/**
* Check if error is a NotFoundError.
*/
export function isNotFoundError(error: unknown): error is NotFoundError {
return error instanceof NotFoundError;
}
/**
* Check if error is a CreditError.
*/
export function isCreditError(error: unknown): error is CreditError {
return error instanceof CreditError;
}
/**
* Check if error is a ServiceError.
*/
export function isServiceError(error: unknown): error is ServiceError {
return error instanceof ServiceError;
}
/**
* Check if error is a RateLimitError.
*/
export function isRateLimitError(error: unknown): error is RateLimitError {
return error instanceof RateLimitError;
}
/**
* Check if error is a NetworkError.
*/
export function isNetworkError(error: unknown): error is NetworkError {
return error instanceof NetworkError;
}
/**
* Check if error is a DatabaseError.
*/
export function isDatabaseError(error: unknown): error is DatabaseError {
return error instanceof DatabaseError;
}
/**
* Check if error has a specific error code.
* Similar to Go's `errors.Is()`.
*
* @example
* ```typescript
* if (hasErrorCode(error, ErrorCode.INSUFFICIENT_CREDITS)) {
* showUpgradePrompt();
* }
* ```
*/
export function hasErrorCode(error: unknown, code: ErrorCode): boolean {
if (!isAppError(error)) {
return false;
}
return error.hasCode(code);
}
/**
* Find the first error in the chain matching a predicate.
* Traverses the cause chain looking for a matching error.
*
* @example
* ```typescript
* const creditError = findError(error, isCreditError);
* if (creditError) {
* console.log('Required:', creditError.requiredCredits);
* }
* ```
*/
export function findError<T extends AppError>(
error: unknown,
predicate: (e: AppError) => e is T
): T | undefined {
let current: unknown = error;
while (current) {
if (isAppError(current) && predicate(current)) {
return current;
}
current = isAppError(current) ? current.cause : undefined;
}
return undefined;
}
/**
* Check if error is retryable.
* Works with both AppError and standard Error.
*/
export function isRetryable(error: unknown): boolean {
if (isAppError(error)) {
return error.retryable;
}
return false;
}
/**
* Get the HTTP status code for an error.
* Returns 500 for non-AppError errors.
*/
export function getHttpStatus(error: unknown): number {
if (isAppError(error)) {
return error.httpStatus;
}
return 500;
}
/**
* Get the error code for an error.
* Returns UNKNOWN_ERROR for non-AppError errors.
*/
export function getErrorCode(error: unknown): ErrorCode {
if (isAppError(error)) {
return error.code;
}
return ErrorCode.UNKNOWN_ERROR;
}

View file

@ -0,0 +1,108 @@
/**
* @manacore/shared-errors
*
* Go-like error handling system for NestJS backends.
*
* Features:
* - Result<T, E> type for explicit error handling
* - Standardized error codes and HTTP status mappings
* - Error wrapping with context (like Go's fmt.Errorf)
* - Type guards for type-safe error checking (like Go's errors.Is/As)
* - NestJS exception filter for consistent API responses
*
* @example
* ```typescript
* // In a service
* import {
* Result, ok, err, AsyncResult,
* ValidationError, NotFoundError, ServiceError
* } from '@manacore/shared-errors';
*
* async function getUser(id: string): AsyncResult<User> {
* if (!isValidId(id)) {
* return err(ValidationError.invalidInput('id', 'must be a valid UUID'));
* }
*
* const user = await db.findUser(id);
* if (!user) {
* return err(new NotFoundError('User', id));
* }
*
* return ok(user);
* }
*
* // In a controller
* import { isOk } from '@manacore/shared-errors';
*
* const result = await userService.getUser(id);
* if (!isOk(result)) {
* throw result.error; // Caught by AppExceptionFilter
* }
* return result.value;
* ```
*/
// Types
export {
ErrorCode,
ERROR_CODE_TO_HTTP_STATUS,
ERROR_CODE_RETRYABLE,
} from './types/error-codes';
export {
type Result,
type AsyncResult,
ok,
err,
isOk,
isErr,
unwrap,
unwrapOr,
unwrapOrElse,
map,
mapErr,
andThen,
match,
tryCatch,
tryCatchAsync,
combine,
fromNullable,
toNullable,
} from './types/result';
// Errors
export {
AppError,
type ErrorContext,
type AppErrorOptions,
} from './errors/app-error';
export { ValidationError } from './errors/validation-error';
export { AuthError } from './errors/auth-error';
export { NotFoundError } from './errors/not-found-error';
export { CreditError } from './errors/credit-error';
export { ServiceError } from './errors/service-error';
export { RateLimitError } from './errors/rate-limit-error';
export { NetworkError } from './errors/network-error';
export { DatabaseError } from './errors/database-error';
// Guards
export {
isAppError,
isValidationError,
isAuthError,
isNotFoundError,
isCreditError,
isServiceError,
isRateLimitError,
isNetworkError,
isDatabaseError,
hasErrorCode,
findError,
isRetryable,
getHttpStatus,
getErrorCode,
} from './guards/type-guards';
// Utils
export { wrap, toAppError, cause, rootCause } from './utils/wrap';

View file

@ -0,0 +1,259 @@
import {
type ExceptionFilter,
Catch,
type ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import { isAppError, isCreditError, isRateLimitError } from '../guards/type-guards';
import { ErrorCode } from '../types/error-codes';
/**
* Standard error response format returned by all backends.
*/
export interface ErrorResponseBody {
statusCode: number;
error: string;
message: string;
retryable: boolean;
timestamp: string;
path: string;
details?: Record<string, unknown>;
}
/**
* Global exception filter that converts all errors to a consistent format.
*
* Handles:
* - AppError and subclasses (from shared-errors)
* - NestJS HttpException
* - Standard JavaScript Error
* - Unknown errors
*
* @example
* ```typescript
* // In main.ts
* import { AppExceptionFilter } from '@manacore/shared-errors/nestjs';
*
* async function bootstrap() {
* const app = await NestFactory.create(AppModule);
* app.useGlobalFilters(new AppExceptionFilter());
* await app.listen(3000);
* }
* ```
*/
@Catch()
export class AppExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(AppExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const errorResponse = this.buildErrorResponse(exception, request);
this.logError(exception, request, errorResponse);
response.status(errorResponse.statusCode).json(errorResponse);
}
/**
* Build the error response body based on the exception type.
*/
private buildErrorResponse(
exception: unknown,
request: Request
): ErrorResponseBody {
// Handle AppError and subclasses
if (isAppError(exception)) {
return this.buildAppErrorResponse(exception, request);
}
// Handle NestJS HttpException
if (exception instanceof HttpException) {
return this.buildHttpExceptionResponse(exception, request);
}
// Handle standard Error
if (exception instanceof Error) {
return this.buildStandardErrorResponse(exception, request);
}
// Handle unknown errors
return this.buildUnknownErrorResponse(request);
}
/**
* Build response for AppError and subclasses.
*/
private buildAppErrorResponse(
exception: AppError,
request: Request
): ErrorResponseBody {
const baseResponse: ErrorResponseBody = {
statusCode: exception.httpStatus,
error: exception.code,
message: exception.message,
retryable: exception.retryable,
timestamp: exception.timestamp,
path: request.url,
};
// Add credit-specific fields for CreditError
if (isCreditError(exception)) {
baseResponse.details = {
requiredCredits: exception.requiredCredits,
availableCredits: exception.availableCredits,
...exception.context,
};
}
// Add retry-after for RateLimitError
else if (isRateLimitError(exception) && exception.retryAfter) {
baseResponse.details = {
retryAfter: exception.retryAfter,
...exception.context,
};
}
// Add other context if present
else if (Object.keys(exception.context).length > 0) {
baseResponse.details = exception.context;
}
return baseResponse;
}
/**
* Build response for NestJS HttpException.
*/
private buildHttpExceptionResponse(
exception: HttpException,
request: Request
): ErrorResponseBody {
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
let message: string;
let details: Record<string, unknown> | undefined;
if (typeof exceptionResponse === 'object') {
const responseObj = exceptionResponse as Record<string, unknown>;
message =
typeof responseObj.message === 'string'
? responseObj.message
: Array.isArray(responseObj.message)
? (responseObj.message as string[]).join(', ')
: exception.message;
// Extract any additional details
const { message: _, error: __, statusCode: ___, ...rest } = responseObj;
if (Object.keys(rest).length > 0) {
details = rest;
}
} else {
message = String(exceptionResponse);
}
return {
statusCode: status,
error: this.httpStatusToErrorCode(status),
message,
retryable: status >= 500,
timestamp: new Date().toISOString(),
path: request.url,
...(details && { details }),
};
}
/**
* Build response for standard JavaScript Error.
*/
private buildStandardErrorResponse(
exception: Error,
request: Request
): ErrorResponseBody {
const isProduction = process.env.NODE_ENV === 'production';
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
error: ErrorCode.INTERNAL_ERROR,
message: isProduction
? 'An unexpected error occurred'
: exception.message,
retryable: true,
timestamp: new Date().toISOString(),
path: request.url,
};
}
/**
* Build response for unknown error types.
*/
private buildUnknownErrorResponse(request: Request): ErrorResponseBody {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
error: ErrorCode.UNKNOWN_ERROR,
message: 'An unexpected error occurred',
retryable: true,
timestamp: new Date().toISOString(),
path: request.url,
};
}
/**
* Map HTTP status code to ErrorCode.
*/
private httpStatusToErrorCode(status: number): string {
const statusToCode: Record<number, string> = {
400: ErrorCode.VALIDATION_FAILED,
401: ErrorCode.AUTHENTICATION_REQUIRED,
402: ErrorCode.PAYMENT_REQUIRED,
403: ErrorCode.PERMISSION_DENIED,
404: ErrorCode.RESOURCE_NOT_FOUND,
409: ErrorCode.CONFLICT,
429: ErrorCode.RATE_LIMIT_EXCEEDED,
500: ErrorCode.INTERNAL_ERROR,
502: ErrorCode.EXTERNAL_SERVICE_ERROR,
503: ErrorCode.SERVICE_UNAVAILABLE,
504: ErrorCode.TIMEOUT,
};
return statusToCode[status] || ErrorCode.UNKNOWN_ERROR;
}
/**
* Log the error with appropriate level based on status code.
*/
private logError(
exception: unknown,
request: Request,
response: ErrorResponseBody
): void {
const logData = {
method: request.method,
url: request.url,
statusCode: response.statusCode,
error: response.error,
message: response.message,
userId: (request as Request & { user?: { sub?: string } }).user?.sub,
};
// Log 5xx errors as errors, others as warnings
if (response.statusCode >= 500) {
this.logger.error(
`[${logData.method}] ${logData.url} - ${logData.statusCode}: ${logData.message}`,
isAppError(exception)
? JSON.stringify(exception.toFullJSON(), null, 2)
: exception instanceof Error
? exception.stack
: undefined
);
} else {
this.logger.warn(
`[${logData.method}] ${logData.url} - ${logData.statusCode}: ${logData.message}`
);
}
}
}

View file

@ -0,0 +1 @@
export { AppExceptionFilter, type ErrorResponseBody } from './app-exception.filter';

View file

@ -0,0 +1,162 @@
/**
* Standardized error codes across all backends.
* Follows pattern: CATEGORY_SPECIFIC_ERROR
*/
export enum ErrorCode {
// Validation Errors (400)
VALIDATION_FAILED = 'VALIDATION_FAILED',
INVALID_INPUT = 'INVALID_INPUT',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
INVALID_FORMAT = 'INVALID_FORMAT',
// Authentication Errors (401)
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
// Authorization Errors (403)
PERMISSION_DENIED = 'PERMISSION_DENIED',
RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED',
// Not Found Errors (404)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
// Payment/Credit Errors (402)
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
// Conflict Errors (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// Rate Limiting (429)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
// Service Errors (500)
INTERNAL_ERROR = 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
GENERATION_FAILED = 'GENERATION_FAILED',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
// Network Errors (502/503/504)
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
CONNECTION_REFUSED = 'CONNECTION_REFUSED',
// Database Errors
DATABASE_ERROR = 'DATABASE_ERROR',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Unknown
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}
/**
* Maps error codes to default HTTP status codes.
*/
export const ERROR_CODE_TO_HTTP_STATUS: Record<ErrorCode, number> = {
// Validation (400)
[ErrorCode.VALIDATION_FAILED]: 400,
[ErrorCode.INVALID_INPUT]: 400,
[ErrorCode.MISSING_REQUIRED_FIELD]: 400,
[ErrorCode.INVALID_FORMAT]: 400,
// Authentication (401)
[ErrorCode.AUTHENTICATION_REQUIRED]: 401,
[ErrorCode.INVALID_TOKEN]: 401,
[ErrorCode.TOKEN_EXPIRED]: 401,
// Authorization (403)
[ErrorCode.PERMISSION_DENIED]: 403,
[ErrorCode.RESOURCE_NOT_OWNED]: 403,
// Not Found (404)
[ErrorCode.RESOURCE_NOT_FOUND]: 404,
[ErrorCode.USER_NOT_FOUND]: 404,
// Payment (402)
[ErrorCode.INSUFFICIENT_CREDITS]: 402,
[ErrorCode.PAYMENT_REQUIRED]: 402,
// Conflict (409)
[ErrorCode.CONFLICT]: 409,
[ErrorCode.DUPLICATE_ENTRY]: 409,
// Rate Limit (429)
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
[ErrorCode.TOO_MANY_REQUESTS]: 429,
// Service Errors (500)
[ErrorCode.INTERNAL_ERROR]: 500,
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
[ErrorCode.GENERATION_FAILED]: 500,
[ErrorCode.EXTERNAL_SERVICE_ERROR]: 502,
// Network Errors
[ErrorCode.NETWORK_ERROR]: 502,
[ErrorCode.TIMEOUT]: 504,
[ErrorCode.CONNECTION_REFUSED]: 503,
// Database Errors
[ErrorCode.DATABASE_ERROR]: 500,
[ErrorCode.CONSTRAINT_VIOLATION]: 409,
// Unknown
[ErrorCode.UNKNOWN_ERROR]: 500,
};
/**
* Maps error codes to default retryable status.
*/
export const ERROR_CODE_RETRYABLE: Record<ErrorCode, boolean> = {
// Validation - not retryable (user needs to fix input)
[ErrorCode.VALIDATION_FAILED]: false,
[ErrorCode.INVALID_INPUT]: false,
[ErrorCode.MISSING_REQUIRED_FIELD]: false,
[ErrorCode.INVALID_FORMAT]: false,
// Authentication - not retryable (need new credentials)
[ErrorCode.AUTHENTICATION_REQUIRED]: false,
[ErrorCode.INVALID_TOKEN]: false,
[ErrorCode.TOKEN_EXPIRED]: false,
// Authorization - not retryable (permission issue)
[ErrorCode.PERMISSION_DENIED]: false,
[ErrorCode.RESOURCE_NOT_OWNED]: false,
// Not Found - not retryable (resource doesn't exist)
[ErrorCode.RESOURCE_NOT_FOUND]: false,
[ErrorCode.USER_NOT_FOUND]: false,
// Payment - not retryable (need more credits)
[ErrorCode.INSUFFICIENT_CREDITS]: false,
[ErrorCode.PAYMENT_REQUIRED]: false,
// Conflict - not retryable (data issue)
[ErrorCode.CONFLICT]: false,
[ErrorCode.DUPLICATE_ENTRY]: false,
// Rate Limit - retryable (after waiting)
[ErrorCode.RATE_LIMIT_EXCEEDED]: true,
[ErrorCode.TOO_MANY_REQUESTS]: true,
// Service Errors - retryable (transient issues)
[ErrorCode.INTERNAL_ERROR]: true,
[ErrorCode.SERVICE_UNAVAILABLE]: true,
[ErrorCode.GENERATION_FAILED]: true,
[ErrorCode.EXTERNAL_SERVICE_ERROR]: true,
// Network Errors - retryable (transient issues)
[ErrorCode.NETWORK_ERROR]: true,
[ErrorCode.TIMEOUT]: true,
[ErrorCode.CONNECTION_REFUSED]: true,
// Database Errors - not retryable (except transient, but safer to say no)
[ErrorCode.DATABASE_ERROR]: false,
[ErrorCode.CONSTRAINT_VIOLATION]: false,
// Unknown - retryable (might be transient)
[ErrorCode.UNKNOWN_ERROR]: true,
};

View file

@ -0,0 +1,2 @@
export * from './error-codes';
export * from './result';

View file

@ -0,0 +1,331 @@
import { AppError } from '../errors/app-error';
import { ErrorCode } from './error-codes';
/**
* Result type representing either success or failure.
* Inspired by Go's (value, error) return pattern and Rust's Result type.
*
* @example
* ```typescript
* // In a service
* async function getUser(id: string): AsyncResult<User> {
* const user = await db.findUser(id);
* if (!user) {
* return err(new NotFoundError('User', id));
* }
* return ok(user);
* }
*
* // In a controller (Go-like explicit unwrap)
* const result = await userService.getUser(id);
* if (!isOk(result)) {
* throw result.error;
* }
* return result.value;
* ```
*/
export type Result<T, E extends AppError = AppError> =
| { readonly ok: true; readonly value: T; readonly error?: never }
| { readonly ok: false; readonly error: E; readonly value?: never };
/**
* Async version of Result - use this as return type for async functions.
*/
export type AsyncResult<T, E extends AppError = AppError> = Promise<Result<T, E>>;
/**
* Create a success Result.
*
* @example
* ```typescript
* return ok({ name: 'John', email: 'john@example.com' });
* ```
*/
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
/**
* Create a failure Result.
*
* @example
* ```typescript
* return err(new ValidationError('Invalid email'));
* return err(NotFoundError.user(userId));
* ```
*/
export function err<E extends AppError>(error: E): Result<never, E> {
return { ok: false, error };
}
/**
* Check if Result is success.
* Use this for type narrowing in conditionals.
*
* @example
* ```typescript
* const result = await service.getData();
* if (isOk(result)) {
* console.log(result.value); // TypeScript knows value exists
* }
* ```
*/
export function isOk<T, E extends AppError>(
result: Result<T, E>
): result is { ok: true; value: T } {
return result.ok === true;
}
/**
* Check if Result is failure.
* Use this for type narrowing in conditionals.
*
* @example
* ```typescript
* const result = await service.getData();
* if (isErr(result)) {
* console.error(result.error.message); // TypeScript knows error exists
* }
* ```
*/
export function isErr<T, E extends AppError>(
result: Result<T, E>
): result is { ok: false; error: E } {
return result.ok === false;
}
/**
* Unwrap the value or throw if error.
* Use sparingly - prefer explicit error checking.
*
* @throws The error if Result is a failure
*
* @example
* ```typescript
* // Use when you want to propagate errors as exceptions
* const value = unwrap(result);
* ```
*/
export function unwrap<T, E extends AppError>(result: Result<T, E>): T {
if (isOk(result)) {
return result.value;
}
throw result.error;
}
/**
* Unwrap the value or return a default value.
*
* @example
* ```typescript
* const users = unwrapOr(result, []); // Returns [] if error
* ```
*/
export function unwrapOr<T, E extends AppError>(
result: Result<T, E>,
defaultValue: T
): T {
return isOk(result) ? result.value : defaultValue;
}
/**
* Unwrap the value or compute a default from the error.
*
* @example
* ```typescript
* const value = unwrapOrElse(result, (error) => {
* console.error('Failed:', error.message);
* return fallbackValue;
* });
* ```
*/
export function unwrapOrElse<T, E extends AppError>(
result: Result<T, E>,
fn: (error: E) => T
): T {
return isOk(result) ? result.value : fn(result.error);
}
/**
* Map the success value to a new value.
*
* @example
* ```typescript
* const result = await getUser(id);
* const nameResult = map(result, user => user.name);
* ```
*/
export function map<T, U, E extends AppError>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
return isOk(result) ? ok(fn(result.value)) : result;
}
/**
* Map the error to a new error.
*
* @example
* ```typescript
* const result = mapErr(originalResult, error =>
* error.wrap('while processing user')
* );
* ```
*/
export function mapErr<T, E extends AppError, F extends AppError>(
result: Result<T, E>,
fn: (error: E) => F
): Result<T, F> {
return isErr(result) ? err(fn(result.error)) : result;
}
/**
* Chain Results (flatMap) - use when the mapping function returns a Result.
*
* @example
* ```typescript
* const result = andThen(getUserResult, user =>
* getPermissions(user.id)
* );
* ```
*/
export function andThen<T, U, E extends AppError>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return isOk(result) ? fn(result.value) : result;
}
/**
* Pattern matching for Result - handle both success and failure cases.
*
* @example
* ```typescript
* const message = match(result, {
* ok: (user) => `Welcome, ${user.name}!`,
* err: (error) => `Error: ${error.message}`,
* });
* ```
*/
export function match<T, E extends AppError, U>(
result: Result<T, E>,
handlers: {
ok: (value: T) => U;
err: (error: E) => U;
}
): U {
return isOk(result) ? handlers.ok(result.value) : handlers.err(result.error);
}
/**
* Try to execute a synchronous function and wrap in Result.
*
* @example
* ```typescript
* const result = tryCatch(() => JSON.parse(jsonString));
* ```
*/
export function tryCatch<T>(fn: () => T): Result<T, AppError> {
try {
return ok(fn());
} catch (error) {
if (error instanceof AppError) {
return err(error);
}
return err(
new AppError({
code: ErrorCode.UNKNOWN_ERROR,
message: error instanceof Error ? error.message : String(error),
cause: error instanceof Error ? error : undefined,
})
);
}
}
/**
* Try to execute an async function and wrap in Result.
*
* @example
* ```typescript
* const result = await tryCatchAsync(() => fetch(url).then(r => r.json()));
* ```
*/
export async function tryCatchAsync<T>(
fn: () => Promise<T>
): AsyncResult<T, AppError> {
try {
return ok(await fn());
} catch (error) {
if (error instanceof AppError) {
return err(error);
}
return err(
new AppError({
code: ErrorCode.UNKNOWN_ERROR,
message: error instanceof Error ? error.message : String(error),
cause: error instanceof Error ? error : undefined,
})
);
}
}
/**
* Combine multiple Results - returns first error or array of all values.
*
* @example
* ```typescript
* const results = await Promise.all([
* getUser(id1),
* getUser(id2),
* getUser(id3),
* ]);
* const combined = combine(results);
* if (isOk(combined)) {
* const [user1, user2, user3] = combined.value;
* }
* ```
*/
export function combine<T, E extends AppError>(
results: Result<T, E>[]
): Result<T[], E> {
const values: T[] = [];
for (const result of results) {
if (isErr(result)) {
return result;
}
values.push(result.value);
}
return ok(values);
}
/**
* Convert a nullable value to a Result.
*
* @example
* ```typescript
* const result = fromNullable(
* maybeUser,
* () => new NotFoundError('User', id)
* );
* ```
*/
export function fromNullable<T, E extends AppError>(
value: T | null | undefined,
errorFn: () => E
): Result<T, E> {
return value != null ? ok(value) : err(errorFn());
}
/**
* Convert a Result to a nullable value (loses error information).
*
* @example
* ```typescript
* const user = toNullable(result); // User | null
* ```
*/
export function toNullable<T, E extends AppError>(
result: Result<T, E>
): T | null {
return isOk(result) ? result.value : null;
}

View file

@ -0,0 +1 @@
export * from './wrap';

View file

@ -0,0 +1,96 @@
import { AppError, type ErrorContext } from '../errors/app-error';
import { ErrorCode } from '../types/error-codes';
import { isAppError } from '../guards/type-guards';
/**
* Wrap an error with context.
* Similar to Go's `fmt.Errorf("context: %w", err)`.
*
* @param error - The error to wrap (can be any type)
* @param context - Description of the operation that failed
* @param additionalContext - Extra context data to include
* @returns An AppError with the original as its cause
*
* @example
* ```typescript
* try {
* await fetchData();
* } catch (error) {
* return err(wrap(error, 'fetching user data'));
* }
* ```
*/
export function wrap(
error: unknown,
context: string,
additionalContext?: ErrorContext
): AppError {
if (isAppError(error)) {
return error.wrap(context, additionalContext);
}
const message = error instanceof Error ? error.message : String(error);
return new AppError({
code: ErrorCode.UNKNOWN_ERROR,
message: `${context}: ${message}`,
cause: error instanceof Error ? error : undefined,
context: additionalContext,
});
}
/**
* Convert any error to AppError.
* If already an AppError, returns it unchanged.
*
* @example
* ```typescript
* try {
* await riskyOperation();
* } catch (error) {
* return err(toAppError(error));
* }
* ```
*/
export function toAppError(error: unknown): AppError {
if (isAppError(error)) {
return error;
}
if (error instanceof Error) {
return new AppError({
code: ErrorCode.UNKNOWN_ERROR,
message: error.message,
cause: error,
});
}
return new AppError({
code: ErrorCode.UNKNOWN_ERROR,
message: String(error),
});
}
/**
* Get the cause of an error.
*
* @example
* ```typescript
* const originalError = cause(wrappedError);
* ```
*/
export function cause(error: AppError): Error | undefined {
return error.cause;
}
/**
* Get the root cause of an error chain.
* Traverses all causes to find the original error.
*
* @example
* ```typescript
* const original = rootCause(deeplyWrappedError);
* ```
*/
export function rootCause(error: AppError): Error {
return error.rootCause();
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}