index.js

/*!
 * @moyal/js-error - A modern, extensible JavaScript error class with native cause support.
 *
 * File: moyal.linq.js
 * Repository: https://github.com/IlanMoyal/moyal.js.error
 * Author: Ilan Moyal (https://www.moyal.es)
 * Contact: ilan.amoyal[guess...what]gmail.com
 *
 * Description:
 * A modern, extensible JavaScript error class with native cause support, recursive 
 * serialization, full stack tracing, and developer-friendly utilities. Designed for 
 * structured logging and forward-compatible error handling in modern applications.
 * 
 * License:
 * MIT License – Permission is granted for free use, modification, and distribution,
 * provided that the copyright notice and license appear in all copies.
 * Full license text: https://opensource.org/licenses/MIT
 *
 * © 2000–present Ilan Moyal. All rights reserved.
 */

const TAB_STR = "  ";

import { isString } from "@moyal/js-type";
import BuildInfo from "./auto-generated/build-info.js";

/**
 * @typedef {object} MoyalErrorJSON
 * @property {string} name - The name of the error (usually the class name).
 * @property {string} message - The error message.
 * @property {string} timestamp - ISO 8601 timestamp of when the error was created.
 * @property {string | undefined} stack - The error's stack trace.
 * @property {MoyalErrorJSON | object | string | null} [cause] - The nested cause (recursively serialized if available).
 * @property {string} type - The actual class name (e.g., "MoyalError").
 */

/**
 * Returns the semantic version of this library.
 * @returns {string} - The semantic version of this library.
 */
export function version() { return BuildInfo.version; }

/**
 * Custom error class with nested error support.
 * Automatically uses native `cause` if supported, otherwise simulates it.
 */
export class MoyalError extends Error {
	/**
	 * Returns the version of this library.
	 * 
	 * @returns {string} - The version of this library.
	 */
	static get version() { 
		return BuildInfo.version;
	}

	/**
	 * @readonly 
	 * @type {boolean} 
	 */
	static #_supportsNativeCause = MoyalError.#_testCauseSupport();

	/**
	 * Tests if the current runtime supports native Error `cause` (ES2022).
	 * @returns {boolean}
	 */
	static #_testCauseSupport() {
		try {
			const test = new Error("test", { cause: new Error("inner") });
			return 'cause' in test;
		} catch {
			return false;
		}
	}

	/** @type {Error | undefined} */
	#_cause = undefined;

	/**@type {Date} */
	#_timestamp = new Date();

	/**
	 * Constructs a new MoyalError instance.
	 *
	 * @param {string} message - The error message.
	 * @param {object|Error|string|null} [second] - Either an object with `{ cause }`, or a direct cause/error.
	 */
	constructor(message = "No message description was set to this error.", second = null) {
		// Handle options object with { cause } explicitly
		const isOptionsObject = second && !(second instanceof Error) && typeof second === 'object' && 'cause' in second;
		const cause = isOptionsObject ? second.cause : second;
		const restOptions = MoyalError.#_supportsNativeCause ? { cause } : undefined;
		
		// Call parent with cause if supported
		super(String(message), restOptions);

		this.name = new.target.name; // <- ensures 'MoyalError' instead of 'Error'

		// Fallback storage for cause
		if (!MoyalError.#_supportsNativeCause && cause instanceof Error) {
			this.#_cause = cause;
		}
	}

	/**
	 * Gets the cause of the error (native or simulated).
	 * @returns {Error | undefined}
	 */
	get cause() {
		return MoyalError.#_supportsNativeCause
			? super.cause
			: this.#_cause;
	}

	/**
	 * Returns a string representation of the error including chained causes.
	 * @returns {string}
	 */
	toString() {
		let s = this.#_toStringNonRecursive();
		const cause = this.cause;
		if (cause) {
			s += `\nCaused by...\n` + MoyalError.#_tabify(cause.toString());
		}
		return s;
	}

	/**
	 * Returns the error string without recursing into causes.
	 * @returns {string}
	 */
	#_toStringNonRecursive() {
		let s = this.name;
		if (this.message) s += `: ${this.message}`;
		if (this.stack) s += `\n${MoyalError.#_tabify(this.stack)}`;
		return s;
	}
	
	/**
	 * Indents all lines with a tab.
	 * @param {string} str
	 * @returns {string}
	 */
	static #_tabify(str) {
		return String(str).replace(/\n/g, `\n${TAB_STR}`);
	}

	/**
	 * Serializes the error into a JSON-friendly object, including metadata for structured logging.
	 *
	 * @returns {MoyalErrorJSON} A plain object with name, message, stack, timestamp, and cause.
	 */
	toJSON() {
		const cause = this.cause;
		return {
			name: this.name,
			type: this.constructor.name,
			timestamp: this.#_timestamp.toISOString(),
			message: this.message,
			stack: this.stack,
			cause: cause instanceof Error && typeof cause.toJSON === 'function'
				? cause.toJSON()
				: cause ?? null
		};
	}

	/**
	 * Builds a full stack trace including all nested causes, recursively.
	 *
	 * @returns {string} A combined stack trace with each cause appended.
	 */
	get fullStack() {
		let output = this.stack || "";
		let current = this.cause;
		while (current) {
			output += `\nCaused by: ${current.stack || current.message}`;
			current = current.cause;
		}
		return output;
	}
}

/**
 * Builds a readable, indented summary of the cause chain.
 *
 * @param {Error} error - The error to trace.
 * @returns {string} A multiline string with each cause indented by depth.
 */
export function printCauseChain(error) {
	let lines = [];
	let depth = 0;
	while (error) {
		lines.push(`${TAB_STR.repeat(depth)}${error.name ?? 'Error'}: ${error.message ?? '(no message)'}`);
		error = error.cause;
		depth++;
	}
	return lines.join('\n');
}

/**
 * Extends the native Javascript Error type to contain toJSON function.
 */
export function extendNativeError() {
	if (!Error.prototype.toJSON) {
		Error.prototype.toJSON = function () {
			return {
				name: this.name,
				type: this.constructor.name,
				message: this.message,
				stack: this.stack,
				...(this.cause && { cause: this.cause })
			};
		};
	}
}

/**
 * Represents an error caused by invalid or missing function arguments.
 * Inherits from `MoyalError`.
 *
 * @extends {MoyalError}
 */
export class ArgumentError extends MoyalError {
	/** @type {string|null} @private */
	#_argumentName = null;
	
	/**
	 * Constructs a new ArgumentError.
	 *
	 * @param {string} message - The error message.
	 * @param {string} [argumentName] - The name of the argument that caused the error.
	 */
	constructor(message, argumentName) {
		super(message);
		this.#_argumentName = argumentName;
	}
	
	/**
	 * Gets the name of the argument that caused the error.
	 * 
	 * @returns {string|null}
	 */
	get argumentName() {
		return this.#_argumentName;
	}
}

/**
 * Throws an {@link ArgumentError} if the given value is `null`.
 *
 * @param {*} value - The value to check.
 * @param {string} [argumentName] - The name of the argument being checked.
 * @throws {ArgumentError}
 */
export function throwIfNull(value, argumentName) {
	if(value === null)
		throw new ArgumentError(`${(argumentName ?? "argument")} can not be 'null'`, argumentName)
}

/**
 * Throws an {@link ArgumentError} if the given value is `undefined`.
 *
 * @param {*} value - The value to check.
 * @param {string} [argumentName] - The name of the argument being checked.
 * @throws {ArgumentError}
 */
export function throwIfUndefined(value, argumentName) {
	if(value === undefined)
		throw new ArgumentError(`${(argumentName ?? "argument")} can not be 'undefined'`, argumentName)
}

/**
 * Throws an {@link ArgumentError} if the given value is `null` or `undefined`.
 *
 * @param {*} value - The value to check.
 * @param {string} [argumentName] - The name of the argument being checked.
 * @throws {ArgumentError}
 */
export function throwIfNullOrUndefined(value, argumentName) {
	if(value == null)
		throw new ArgumentError(`${(argumentName ?? "argument")} can not be 'null' or 'undefined'`, argumentName)
}

/**
 * Always throws an {@link ArgumentError} indicating a required argument is missing.
 *
 * @param {string} [argumentName] - The name of the missing argument.
 * @throws {ArgumentError}
 */
export function throwMissingArgument(argumentName) {
	throw new ArgumentError(`${(argumentName ?? "argument")} is missing`, argumentName)
}

/**
 * Throws an {@link ArgumentError} if the given value is an empty string.
 *
 * @param {*} value - The value to check.
 * @param {string} [argumentName] - The name of the argument being checked.
 * @throws {ArgumentError}
 */
export function throwIfEmptyString(value, argumentName) {
	if (value === "") 
		throw new ArgumentError(`${argumentName ?? "argument"} cannot be an empty string`, argumentName);
}

/**
 * Throws an {@link ArgumentError} if the given value is `null`, `undefined`,
 * or a string that is empty or only whitespace.
 *
 * @param {*} value - The value to check.
 * @param {string} [argumentName] - The name of the argument being checked.
 * @throws {ArgumentError}
 */
export function throwIfNullOrWhitespace(value, argumentName) {
	/* actually null, undefined or empty string (if it is a string) */
	if (value == null || (isString(value) && value.trim() === ""))
		throw new ArgumentError(`${argumentName ?? "argument"} cannot be null, empty, or whitespace`, argumentName);
}