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
text/typescript
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;
}