UNPKG

rc-js-util

Version:

A collection of TS and C++ utilities to help writing performant and correct applications, achieved through strict typing and (removable) invariant checking.

267 lines (238 loc) 8.66 kB
import { _Debug } from "../debug/_debug.js"; /** * @public * An error which can have a chain of causes. */ export interface INestedError<TLocalization> { readonly stack: string; readonly causedBy: unknown; /** * @returns The localized error message for the user. */ getMessage(): TLocalization; /** * Create a localized error message that describes what went wrong, and includes some minimal * technical detail that's likely to describe the root cause. */ composeErrorMessages(): IErrorSummary<TLocalization>; /** * Attempt to serialize `causedBy`, where this is not possible, undefined is returned. */ causeToString(): string | undefined; /** * Stringifies the causes of the error, following the chain. This is intended for developers and includes * stack traces at each step. */ toString(): string; } /** * @public * Constructor to {@link INestedError}. */ export interface INestedErrorCtor<TLocalization, TInstance extends INestedError<TLocalization>> { new(message: TLocalization, causedBy: unknown): TInstance; isError(error: unknown): error is TInstance; getRootCause(error: unknown): TInstance; normalizeError(error: unknown): TInstance; } /** * @public * A flattened {@link INestedError}, ready to be localized and shown to the user. */ export interface IErrorSummary<TLocalization> { /** * A user-friendly description of what went wrong. The stringified localization can be joined to form a sentence. */ messages: TLocalization[]; /** * Can be anything libraries are throwing, failing that a stack trace. Generally not * appropriate to show to the user, except as additional information after the localized messages. */ detail?: string; } /** * @public * The config used to create `NestedError` constructors, used by {@link getNestedErrorCtor}. */ export interface INestedErrorCtorConfig<ILocalization> { /** * Given a localization, convert it to a somewhat human-readable string. This should NOT be using any localization system * if there's any chance it can fail to load. This is intended for cases where the localization system is not available. */ getTxFallback: (localization: ILocalization) => string; /** * In the event that an error is not an extensions of this class, this message will be used. */ defaultError: ILocalization; } /** * @public * Factory for creating a localized `NestedError` class, see {@link INestedError}. This should be used * to create a base class, from which you can create extensions to represent specific error cases. */ export function getNestedErrorCtor<TLocalization> ( config: INestedErrorCtorConfig<TLocalization>, ) : INestedErrorCtor<TLocalization, INestedError<TLocalization>> { return class NestedError implements INestedError<TLocalization> { public static isError(error: unknown): error is NestedError { return error instanceof this; } public static normalizeError(error: unknown): NestedError { if (NestedError.isError(error)) { return error; } return new NestedError(NestedError.ctorConfig.defaultError, error); } public static getRootCause(error: unknown): NestedError { const normalizedError = NestedError.normalizeError(error); if (NestedError.isError(normalizedError.causedBy)) { return NestedError.getRootCause(normalizedError.causedBy); } else { return normalizedError; } } public readonly stack: string; public constructor ( protected readonly message: TLocalization, public readonly causedBy: unknown, ) { this.stack = _Debug.getStackTrace(); } public getMessage(): TLocalization { return this.message; } public composeErrorMessages(): IErrorSummary<TLocalization> { const messages: TLocalization[] = [this.getMessage()]; // we're only ever interested in the innermost exception for stack traces etc. let exceptionDetail: string = this.stack; // eslint-disable-next-line @typescript-eslint/no-this-alias let currentError: NestedError = this; while (currentError.causedBy != null) { if (NestedError.isError(currentError.causedBy)) { messages.push(currentError.causedBy.message); currentError = currentError.causedBy; } else { const detail = currentError.causeToString(); if (detail != null) { // it's likely to provide more context than the composable error stack trace exceptionDetail = detail; } break; } } return { messages: messages, detail: exceptionDetail, }; } public causeToString(): string | undefined { if (this.causedBy == null) { return undefined; } if (this.causedBy instanceof NestedError) { // one of ours... return this.causedBy.toString(); } if (this.causedBy instanceof Error) { // firefox sometimes populates things with empty strings (e.g. network errors) // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (this.causedBy.message && this.causedBy.stack) { return [this.causedBy.stack, this.stack].join("\n"); } if (this.causedBy.message) { return this.causedBy.message; } // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (this.causedBy.stack) { return this.causedBy.stack; } // it's an error, but not as we know it... return guardedToString(this.causedBy); } switch (typeof this.causedBy) { case "string": return this.causedBy; case "object": // generally speaking, an object's toString method doesn't produce useful output // => don't throw random objects... return JSON.stringify(this.causedBy); case "boolean": case "number": case "function": case "symbol": case "bigint": // hanging offense if you actually hit this return this.causedBy.toString(); case "undefined": // we checked for == null above default: // notionally it's not possible to get here, but just in case _BUILD.DEBUG && _Debug.error(`unexpected code path, unknown type ${typeof this.causedBy}`); return "Received non-serializable exception, skipping."; } } public toString(): string { const cause = this.causeToString(); if (cause == null) { return [ NestedError.ctorConfig.getTxFallback(this.getMessage()), this.stack, ].join("\n"); } else { return [ NestedError.ctorConfig.getTxFallback(this.getMessage()), this.stack + "\n", `=======================CAUSE FOLLOWS=======================`, cause ].join("\n"); } } private static ctorConfig = config; }; } // guard against very silly toString overrides... function guardedToString(object: object): string | undefined { if (typeof object.toString !== "function") { return undefined; } // eslint-disable-next-line @typescript-eslint/no-base-to-string const result = object.toString() as unknown; return typeof result === "string" ? result : undefined; }