import { serializeCause } from './serialize-cause.js' /** * Any class that extends Error (used by findCause) */ type AnyErrorClass = new (...args: any[]) => Error /** * Walk the .cause chain of an error to find an ancestor matching a specific error class. * Checks the error itself first, then traverses .cause recursively. * Similar to Go's `errors.As`. * * @example * const notFound = findCause(err, NotFoundError) * if (notFound) { * console.log(notFound.id) // type-safe access * } * * @example * // With optional chaining * const id = findCause(err, NotFoundError)?.id */ export function findCause( error: Error, ErrorClass: new (...args: any[]) => T, ): T | undefined { const seen = new Set() let current: unknown = error while (current instanceof Error) { if (seen.has(current)) break seen.add(current) if (current instanceof ErrorClass) return current as T current = current.cause } return undefined } /** * Any tagged error (for generic constraints) */ type AnyTaggedError = Error & { readonly _tag: string } /** * Type guard for any tagged error */ const isAnyTaggedError = (value: unknown): value is AnyTaggedError => { return ( value instanceof Error && '_tag' in value && typeof value._tag === 'string' ) } /** * Any class that extends Error */ type ErrorClass = new (...args: any[]) => Error /** * Instance type produced by TaggedError factory */ export type TaggedErrorInstance< Tag extends string, Props, Base extends Error = Error, > = Base & { readonly _tag: Tag /** Stable fingerprint for error grouping in Sentry/logging. Returns [_tag]. */ readonly fingerprint: readonly [Tag] toJSON(): object /** Walk the .cause chain to find an ancestor matching a specific error class. */ findCause( ErrorClass: new (...args: any[]) => T, ): T | undefined } & Readonly /** * Class type produced by TaggedError factory */ export type TaggedErrorClass< Tag extends string, Props, Base extends Error = Error, > = { new ( ...args: keyof Props extends never ? [args?: {}] : [args: Props] ): TaggedErrorInstance /** Type guard for this error class */ is(value: unknown): value is TaggedErrorInstance } /** * Factory for tagged error classes with discriminated _tag property. * Enables exhaustive pattern matching on error unions. * * @example * class NotFoundError extends TaggedError("NotFoundError")<{ * id: string; * message: string; * }>() {} * * const err = new NotFoundError({ id: "123", message: "Not found" }); * err._tag // "NotFoundError" * err.id // "123" * * // Type guard * NotFoundError.is(err) // true * TaggedError.is(err) // true (any tagged error) * * @example * // With custom base class * class AppError extends Error { * statusCode: number = 500 * report() { console.log(this.message) } * } * * class NotFoundError extends TaggedError("NotFoundError", AppError)<{ * id: string; * message: string; * }>() { * statusCode = 404 * } * * const err = new NotFoundError({ id: "123", message: "Not found" }); * err.statusCode // 404 * err.report() // works */ export const TaggedError: { ( tag: Tag, BaseClass?: BaseClass, ): = {}>() => TaggedErrorClass< Tag, Props, InstanceType > /** Type guard for any TaggedError instance */ is(value: unknown): value is AnyTaggedError } = Object.assign( ( tag: Tag, BaseClass?: BaseClass, ) => = {}>(): TaggedErrorClass< Tag, Props, InstanceType > => { const ActualBase = (BaseClass ?? Error) as typeof Error // Keys that are managed internally and must not be overwritten by user props const RESERVED_KEYS = new Set([ '_tag', 'fingerprint', 'name', 'stack', 'message', 'cause', ]) class Tagged extends ActualBase { readonly _tag: Tag = tag get fingerprint(): readonly [Tag] { return [this._tag] } /** Type guard for this error class */ static is(value: unknown): value is Tagged { return value instanceof Tagged } constructor(args?: Props) { const message = args && 'message' in args && typeof args.message === 'string' ? args.message : undefined const cause = args && 'cause' in args ? args.cause : undefined super(message, cause !== undefined ? { cause } : undefined) if (args) { for (const key of Object.keys(args)) { if (!RESERVED_KEYS.has(key)) { ;(this as Record)[key] = ( args as Record )[key] } } } Object.setPrototypeOf(this, new.target.prototype) this.name = tag if (cause instanceof Error && cause.stack) { const indented = cause.stack.replace(/\n/g, '\n ') this.stack = `${this.stack}\nCaused by: ${indented}` } } findCause( ErrorClass: new (...args: any[]) => T, ): T | undefined { return findCause(this, ErrorClass) } toJSON(): object { return { ...this, _tag: this._tag, name: this.name, message: this.message, fingerprint: this.fingerprint, cause: serializeCause(this.cause), stack: this.stack, } } } return Tagged as unknown as TaggedErrorClass< Tag, Props, InstanceType > }, { is: isAnyTaggedError }, ) /** * Type guard for tagged error instances. * * @example * if (isTaggedError(value)) { value._tag } */ export const isTaggedError = isAnyTaggedError /** * Handler map with required `Error` fallback for plain Error (untagged) */ type MatchHandlersWithPlain = { [K in Extract['_tag']]: (err: Extract) => R } & { Error: ( err: Exclude extends never ? Error : Exclude, ) => R } /** * Exhaustive pattern match on error union by _tag. * The `Error` handler is always required as fallback for plain Error instances. * * @example * const message = matchError(err, { * NotFoundError: (e) => `Missing: ${e.id}`, * ValidationError: (e) => `Invalid: ${e.field}`, * Error: (e) => `Unknown error: ${e.message}`, * }); */ export function matchError( err: E, handlers: MatchHandlersWithPlain, ): R { const h = handlers as unknown as Record R> if ('_tag' in err && typeof err._tag === 'string') { const handler = h[err._tag] if (handler) { return handler(err) } } // Fall through to Error handler for plain Error or unknown tagged errors return h['Error'](err) } /** * Partial pattern match with fallback for unhandled tags. * * @example * const message = matchErrorPartial(err, { * NotFoundError: (e) => `Missing: ${e.id}`, * }, (e) => `Unknown: ${e.message}`); */ export function matchErrorPartial( err: E, handlers: Partial>, fallback: (e: E) => R, ): R { const h = handlers as unknown as Record R> if ('_tag' in err && typeof err._tag === 'string') { const handler = h[err._tag] if (handler) { return handler(err) } } // Check for Error handler before fallback const errorHandler = h['Error'] if (errorHandler) { return errorHandler(err) } return fallback(err) } /** * Base class for abort-related errors. * Extend this in custom abort errors so `isAbortError` detects them * even when wrapped in a cause chain. * * @example * class TimeoutError extends errore.createTaggedError({ * name: 'TimeoutError', * message: 'Request timed out for $operation', * extends: errore.AbortError, * }) {} * * controller.abort(new TimeoutError({ operation: 'fetch' })) */ export class AbortError extends Error { constructor(message = 'The operation was aborted', options?: ErrorOptions) { super(message, options) this.name = 'AbortError' } } /** * Check if an error (or any error in its `.cause` chain) is an abort error. * Detects native AbortError (DOMException), errore.AbortError, and any * tagged error that extends errore.AbortError. * * @example * const res = await fetch(url, { signal }) * .catch((e) => new NetworkError({ url, cause: e })) * if (errore.isAbortError(res)) { * // request was aborted — timeout, user cancel, etc. * } */ export function isAbortError(error: unknown): error is Error { const seen = new Set() let current: unknown = error while (current instanceof Error) { if (seen.has(current)) break seen.add(current) // Native DOMException AbortError or direct AbortError (name = 'AbortError') if (current.name === 'AbortError') return true // Tagged errors extending AbortError (createTaggedError overrides name to _tag) if (current instanceof AbortError) return true current = current.cause } return false } /** * Default error type when catching unknown exceptions. */ export class UnhandledError extends TaggedError('UnhandledError')<{ message: string cause: unknown }>() { constructor(args: { cause: unknown }) { const message = args.cause instanceof Error ? `Unhandled exception: ${args.cause.message}` : `Unhandled exception: ${String(args.cause)}` super({ message, cause: args.cause }) } }