diff --git a/src/errors/AppError.ts b/src/errors/AppError.ts new file mode 100644 index 0000000..8edd107 --- /dev/null +++ b/src/errors/AppError.ts @@ -0,0 +1,171 @@ +import type { z } from 'zod' +import { EnhancedError } from './EnhancedError' + +type ValueOf = T[keyof T] + +/** + * Protocol-agnostic error type categorization. + * + * Error types are not coupled to any specific protocol and can be mapped to + * HTTP status codes, gRPC status, message queue error codes, etc. + */ +export const ErrorType = { + /** Invalid request or validation error */ + BAD_REQUEST: 'bad-request', + /** Authentication required or failed */ + UNAUTHENTICATED: 'unauthenticated', + /** Insufficient permissions */ + PERMISSION_DENIED: 'permission-denied', + /** Resource not found */ + NOT_FOUND: 'not-found', + /** Resource conflict or already exists */ + CONFLICT: 'conflict', + /** Rate limit exceeded */ + RATE_LIMIT: 'rate-limit', + /** Internal server error */ + INTERNAL: 'internal', + /** Service unavailable */ + UNAVAILABLE: 'unavailable', +} as const + +/** + * Union type of all error type values. + */ +export type ErrorType = ValueOf + +/** + * Maps error types to HTTP status codes. + * + * This is one example of protocol mapping - you can create similar mappings + * for gRPC status codes, message queue error codes, etc. + * + * @internal + */ +const httpStatusByErrorType: Record = { + [ErrorType.BAD_REQUEST]: 400, + [ErrorType.UNAUTHENTICATED]: 401, + [ErrorType.PERMISSION_DENIED]: 403, + [ErrorType.NOT_FOUND]: 404, + [ErrorType.CONFLICT]: 409, + [ErrorType.RATE_LIMIT]: 429, + [ErrorType.INTERNAL]: 500, + [ErrorType.UNAVAILABLE]: 503, +} + +/** + * Structure of an error definition. + * + * Reusable specifications that combine: + * - Unique error code (used for type discrimination) + * - Error type category (for protocol mapping) + * - Public/internal visibility flag + * - Optional Zod schema for type-safe error details and OpenAPI generation + */ +export interface ErrorDefinition { + /** Unique error code - becomes a literal type for type discrimination */ + code: string + /** Error type category for protocol-agnostic error handling */ + type: ErrorType + /** Whether this error is safe to expose to external consumers */ + isPublic: boolean + /** Optional Zod schema for type inference and OpenAPI schema generation */ + detailsSchema?: z.ZodTypeAny +} + +/** + * Creates an error definition with preserved literal types. + * + * The const type parameter ensures error codes remain literal types rather than + * widening to string, enabling TypeScript type discrimination. + * + * @param def - Error definition object + * @returns Same definition with literal types preserved + */ +export const defineError = (def: T): T => def + +/** + * Infers the TypeScript type of error details from a Zod schema. + * + * If the error definition has a detailsSchema, this extracts the TypeScript + * type using Zod's inference. If no schema is defined, returns undefined. + * + * @internal + */ +type InferDetails = TDef['detailsSchema'] extends z.ZodTypeAny + ? z.infer + : undefined + +/** + * Options for constructing an AppError instance. + * + * Uses conditional types to make details field optional when no schema is defined, + * and required when a schema is present. + * + * @template TDetails - Inferred type of error details from Zod schema + */ +export type AppErrorOptions = { + /** Human-readable error message */ + message: string + /** Optional underlying cause for error chaining */ + cause?: unknown +} & (undefined extends TDetails ? { details?: TDetails } : { details: TDetails }) + +/** + * Type-safe application error with literal error codes and protocol-agnostic error types. + * + * Key features: + * - Literal error codes enable TypeScript type discrimination between different error classes + * - Protocol-agnostic error types can map to HTTP status codes, gRPC status, etc. + * - Zod schemas provide type inference for error details and enable OpenAPI schema generation + * - Single class handles both public and internal errors via isPublic flag + * + * @template T - Error definition with literal code type + */ +export class AppError extends EnhancedError { + /** Literal error code for type discrimination */ + readonly code: T['code'] + /** Protocol-agnostic error type */ + readonly type: T['type'] + /** Whether error details are safe to expose externally */ + readonly isPublic: T['isPublic'] + /** Type-safe error details inferred from Zod schema */ + readonly details?: InferDetails + + private constructor(definition: T, options: AppErrorOptions>) { + super(options.message, { cause: options.cause }) + + this.code = definition.code + this.type = definition.type + this.isPublic = definition.isPublic + this.details = options.details + } + + /** + * Creates an error class from an error definition. + * + * Preserves literal types from the definition, enabling type discrimination. + * The returned class constructor requires details if detailsSchema is defined. + * + * @param definition - Error definition with const assertion for literal types + * @returns Error class with definition bound + */ + static from(definition: T) { + return class extends AppError { + constructor(options: AppErrorOptions>) { + super(definition, options) + } + } + } + + /** + * HTTP status code derived from error type. + * + * Automatically maps ErrorType to appropriate HTTP status without + * requiring manual specification per error instance. + * + * @returns HTTP status code + */ + get httpStatusCode(): number { + return httpStatusByErrorType[this.type] + } +} diff --git a/src/errors/README.md b/src/errors/README.md new file mode 100644 index 0000000..d7df8f1 --- /dev/null +++ b/src/errors/README.md @@ -0,0 +1,312 @@ +# AppError + +A type-safe, protocol-agnostic error handling system that improves upon the existing `InternalError` and `PublicNonRecoverableError` pattern. + +## Why AppError? + +### The Problem with InternalError and PublicNonRecoverableError + +While the previous error system provided type-safe details through generics, it had critical limitations: + +```typescript +// Old approach - type-safe details, but weak error code typing +class ProjectNotFoundError extends InternalError<{ name: string }> { + constructor(name: string) { + super({ + message: 'Project not found', + errorCode: 'PROJECT_NOT_FOUND', // Just a string! + details: { name }, + }) + } +} + +class ProjectNameAlreadyExistsError extends InternalError<{ name: string }> { + constructor(name: string) { + super({ + message: 'Project already exists', + errorCode: 'PROJECT_NAME_ALREADY_EXISTS', // Also just a string! + details: { name }, + }) + } +} + +// ❌ TypeScript CANNOT catch this error! +const test = (): ProjectNotFoundError => { + return new ProjectNameAlreadyExistsError('some-project') // No type error! +} +``` + +**Issues:** +- ❌ **No error code type discrimination**: `errorCode` is just `string`, so TypeScript cannot distinguish between different error classes +- ❌ **HTTP-coupled**: `PublicNonRecoverableError` requires `httpStatusCode`, coupling errors to HTTP protocol +- ❌ **No schema for API contracts**: Error details couldn't be used to generate OpenAPI schemas or define strongly-typed API responses + +### The AppError Solution + +AppError addresses these issues through: + +1. **✅ Literal error code types** - TypeScript can distinguish between error classes +2. **✅ Protocol-agnostic error types** - Map to HTTP, gRPC, or any protocol +3. **✅ Centralized error definitions** - Reusable, maintainable error specs +4. **✅ Zod schemas for OpenAPI & type safety** - Define strongly-typed API responses and generate documentation +5. **✅ Single unified class** - Public and internal errors use the same base class + +## Core Concepts + +### Protocol-Agnostic Error Types + +AppError uses standardized error types that can be mapped to any protocol: + +```typescript +export const ErrorType = { + BAD_REQUEST: 'bad-request', // HTTP 400 / gRPC INVALID_ARGUMENT + UNAUTHENTICATED: 'unauthenticated', // HTTP 401 / gRPC UNAUTHENTICATED + PERMISSION_DENIED: 'permission-denied', // HTTP 403 / gRPC PERMISSION_DENIED + NOT_FOUND: 'not-found', // HTTP 404 / gRPC NOT_FOUND + CONFLICT: 'conflict', // HTTP 409 / gRPC ALREADY_EXISTS + RATE_LIMIT: 'rate-limit', // HTTP 429 / gRPC RESOURCE_EXHAUSTED + INTERNAL: 'internal', // HTTP 500 / gRPC INTERNAL + UNAVAILABLE: 'unavailable', // HTTP 503 / gRPC UNAVAILABLE +} as const +``` + +Example HTTP mapping: +```typescript +const httpStatusByErrorType: Record = { + [ErrorType.BAD_REQUEST]: 400, + [ErrorType.NOT_FOUND]: 404, + // ... +} + +// Access via getter +error.httpStatusCode // Returns appropriate HTTP status +``` + +You could similarly create a gRPC status mapping, message queue error codes, etc. + +### Defining Errors with `defineError` + +Use `defineError` to create error definitions with literal types: + +```typescript +import { defineError, ErrorType, AppError } from './AppError' +import { z } from 'zod' + +// Define your error +const projectNotFoundDef = defineError({ + code: 'PROJECT_NOT_FOUND', // This becomes a LITERAL type! + type: ErrorType.NOT_FOUND, + isPublic: true, + detailsSchema: z.object({ + name: z.string(), + organizationId: z.string().optional(), + }), +}) + +// Create a class from the definition +export class ProjectNotFoundError extends AppError.from(projectNotFoundDef) { + constructor(name: string, organizationId?: string) { + super({ + message: 'Project with provided name was not found', + details: { name, organizationId }, + }) + } +} +``` + +## Literal Error Code Types + +The killer feature: TypeScript can now distinguish between different error classes. + +### Type Discrimination + +```typescript +const projectNotFoundDef = defineError({ + code: 'PROJECT_NOT_FOUND', // Literal type: 'PROJECT_NOT_FOUND' + type: ErrorType.NOT_FOUND, + isPublic: true, + detailsSchema: z.object({ name: z.string() }), +}) + +const projectConflictDef = defineError({ + code: 'PROJECT_NAME_ALREADY_EXISTS', // Literal type: 'PROJECT_NAME_ALREADY_EXISTS' + type: ErrorType.CONFLICT, + isPublic: true, + detailsSchema: z.object({ name: z.string() }), +}) + +export class ProjectNotFoundError extends AppError.from(projectNotFoundDef) {} +export class ProjectNameAlreadyExistsError extends AppError.from(projectConflictDef) {} + +// ✅ TypeScript catches this error! +const test = (): ProjectNotFoundError => { + return new ProjectNameAlreadyExistsError('some-project') + // Error: Type 'ProjectNameAlreadyExistsError' is not assignable to type 'ProjectNotFoundError' + // Types of property 'code' are incompatible + // Type '"PROJECT_NAME_ALREADY_EXISTS"' is not assignable to type '"PROJECT_NOT_FOUND"' +} +``` + +This is impossible with the old approach where `errorCode: string`. + +## Protocol Mapping Examples + +### HTTP Mapping (Built-in) + +```typescript +const error = new ProjectNotFoundError('my-project') +error.httpStatusCode // 404 (from ErrorType.NOT_FOUND) +``` + +### gRPC Mapping (Custom) + +```typescript +import { status } from '@grpc/grpc-js' + +const grpcStatusByErrorType: Record = { + [ErrorType.BAD_REQUEST]: status.INVALID_ARGUMENT, + [ErrorType.UNAUTHENTICATED]: status.UNAUTHENTICATED, + [ErrorType.PERMISSION_DENIED]: status.PERMISSION_DENIED, + [ErrorType.NOT_FOUND]: status.NOT_FOUND, + [ErrorType.CONFLICT]: status.ALREADY_EXISTS, + [ErrorType.RATE_LIMIT]: status.RESOURCE_EXHAUSTED, + [ErrorType.INTERNAL]: status.INTERNAL, + [ErrorType.UNAVAILABLE]: status.UNAVAILABLE, +} + +function toGrpcStatus(error: AppError): status { + return grpcStatusByErrorType[error.type] +} +``` + + +## Advanced Patterns + +### Optional vs Required Details + +Errors without details don't require the details field: + +```typescript +const rateLimitDef = defineError({ + code: 'RATE_LIMIT_EXCEEDED', + type: ErrorType.RATE_LIMIT, + isPublic: true, + // No detailsSchema +}) + +export class RateLimitError extends AppError.from(rateLimitDef) { + constructor() { + super({ + message: 'Too many requests', + // passing details is not allowed + }) + } +} +``` + +Errors with schemas require details: + +```typescript +const validationDef = defineError({ + code: 'VALIDATION_ERROR', + type: ErrorType.BAD_REQUEST, + isPublic: true, + detailsSchema: z.object({ + fields: z.array(z.string()), + }), +}) + +export class ValidationError extends AppError.from(validationDef) { + constructor(fields: string[]) { + super({ + message: 'Validation failed', + details: { fields }, // Required by TypeScript! + }) + } +} +``` + +## Migration Guide + +### From InternalError + +**Before:** +```typescript +class OldError extends InternalError<{ id: string }> { + constructor(id: string) { + super({ + message: 'Something failed', + errorCode: 'SOMETHING_FAILED', // Just a string + details: { id }, + }) + } +} +``` + +**After:** +```typescript +const somethingFailedDef = defineError({ + code: 'SOMETHING_FAILED', // Literal type! + type: ErrorType.INTERNAL, + isPublic: false, + detailsSchema: z.object({ + id: z.string(), + }), +}) + +export class SomethingFailedError extends AppError.from(somethingFailedDef) { + constructor(id: string) { + super({ + message: 'Something failed', + details: { id }, + }) + } +} +``` + +### From PublicNonRecoverableError + +**Before:** +```typescript +class OldPublicError extends PublicNonRecoverableError<{ id: string }> { + constructor(id: string) { + super({ + message: 'Not found', + errorCode: 'NOT_FOUND', + httpStatusCode: 404, // Coupled to HTTP! + details: { id }, + }) + } +} +``` + +**After:** +```typescript +const notFoundDef = defineError({ + code: 'NOT_FOUND', + type: ErrorType.NOT_FOUND, // Protocol-agnostic! + isPublic: true, + detailsSchema: z.object({ + id: z.string(), + }), +}) + +export class NotFoundError extends AppError.from(notFoundDef) { + constructor(id: string) { + super({ + message: 'Not found', + details: { id }, + }) + // httpStatusCode is automatically available if needed + } +} +``` + +## Best Practices + +1. **Use descriptive, unique error codes**: `PROJECT_NOT_FOUND` is better than `NOT_FOUND` +2. **Choose appropriate error types**: Use `NOT_FOUND` for missing resources, `BAD_REQUEST` for validation errors +3. **Leverage Zod for complex types**: Use unions, enums, and nested objects for rich type inference +4. **Mark public appropriately**: Only set `isPublic: true` for errors safe to expose externally +5. **Preserve error chains**: Use `cause` to maintain error context for debugging +6. **Create protocol mappings as needed**: HTTP, gRPC, message queues - map ErrorType to whatever you need