index.js

/*!
 * @moyal/js-type - Minimal, robust type detection and value classification utility for JavaScript.
 *
 * File: moyal.linq.js
 * Repository: https://github.com/IlanMoyal/moyal.js.type
 * Author: Ilan Moyal (https://www.moyal.es)
 * Contact: ilan.amoyal[guess...what]gmail.com
 *
 * Description:
 * moyal.js.type is a lightweight, dependency-free utility library that provides accurate and extensible type-checking functions for JavaScript values, across both browser and Node.js environments. It helps you safely determine value types, including edge cases like user-defined classes, boxed primitives, cross-realm objects, and complex built-in types such as Map, Set, Date, and Promise.
 * Unlike typeof, instanceof, or loose type tricks, this library uses stable techniques like Object.prototype.toString.call(...) and constructor introspection to reliably classify values, even across different JavaScript contexts (e.g., iframes or VMs).
 *
 * 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.
 */

/**
 * Utility functions for working with data types.
 * @module TypeUtils
 */

import BuildInfo from "./auto-generated/build-info.js";

const __primitives = ["string", "number", "bigint", "boolean", "symbol"];
const __object_toString = Object.prototype.toString;

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

/**
 * Determines whether the given function is a user defined class constructor.
 * Inspects the function’s string to determine if it's a class constructor.
 *
 * @param {*} fn - The function to test.
 * @returns {boolean} `true` if the given function is a user defined class constructor.
 */
function isClass(fn) {
	/* assume value is a function */
	const str = Function.prototype.toString.call(fn);
	return /^class\s/.test(str);
}

/**
 * Returns the type name of a given value.
 * - For primitives: returns `"string"`, `"number"`, etc.
 * - For objects: returns class name or `"object"` if plain.
 * - For built-in types like Date, RegExp, Map: returns the constructor name.
 * - For plain objects: returns "object". For arrays: "array".
 * - For classes: returns `"class"` for user defined classes.
 * - For functions: returns `"function"`
 *
 * @param {*} value - The value whose type name to determine.
 * @returns {string} The detected type name.
 */
export function getTypeName(value) {
	if(value === null)
		return "null";

	let name = typeof value;
	if (name === "function") {
		if(isClass(value)) 
			name = "class";
	}
	else if (name === "object") {
		name = __object_toString.call(value).slice(8, -1).toLowerCase();
		if(!__primitives.includes(name)) {
			const ctor = value.constructor;
			if (ctor && typeof ctor.name === "string") {
				name = ctor.name;
				if(name === "Object" || name === "Array")
					name = name.toLowerCase();
			}
		}
	}
	return name;
}

/**
 * Checks if the specified value matches the given type name.
 * @param {*} value - The value to test.
 * @param {string} typeName - The expected type name.
 * @returns {boolean} `true` if the value’s type matches the given name.
 */
export function isType(value, typeName) {
	return getTypeName(value) === typeName;
}

/**
 * Checks if the specified value is a string (primitive or String object).
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the specified value is a string.
 */
export function isString(value) { return getTypeName(value) === "string"; }

/**
 * Checks if the specified value is a symbol.
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the specified value is a symbol.
 */
export function isSymbol(value) { return getTypeName(value) === "symbol"; }

/**
 * Checks whether the specified value is a number (primitive or Number object).
 * Excludes NaN and Infinity by default unless explicitly allowed.
 *
 * @param {*} value - The value to test.
 * @param {Object} [options]
 * @param {boolean} [options.allowNaN=true] - Whether to consider NaN a valid number.
 * @param {boolean} [options.allowInfinity=true] - Whether to consider Infinity/-Infinity valid.
 * @returns {boolean} - `true` if the specified value is a number.
 */
export function isNumber(value, { allowNaN = true, allowInfinity = true } = {}) {
	if (getTypeName(value) !== "number") return false;

	const num = Number(value);
	if (!allowNaN && Number.isNaN(num)) return false;
	if (!allowInfinity && !Number.isFinite(num)) return false;

	return true;
}

/**
 * Checks if the specified value is a bigint (primitive or BigInt object).
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is big integer.
 */
export function isBigInt(value) { return getTypeName(value) === "bigint"; }

/**
 * Checks if the value is a numeric type (either number or bigint).
 * @param {*} value
 * @returns {boolean} - `true` if the value is a numeric type (either number or bigint).
 */
export function isNumeric(value) {
	return isNumber(value) || isBigInt(value);
}

/**
 * Checks if the specified value is a boolean (primitive or Boolean object).
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is boolean.
 */
export function isBoolean(value) { return getTypeName(value) === "boolean"; }

/**
 * Checks if the specified value is undefined.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is undefined.
 */
export function isUndefined(value) { return value === undefined; }

/**
 * Checks if the specified value is null.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is null.
 */
export function isNull(value) { return value === null; }

/**
 * Checks if the specified value is a standard function.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is Function.
 */
export function isFunction(value) { return getTypeName(value) === "function"; }

/**
 * Checks if the specified value is a user defined class.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is user defined class.
 */
export function isUserDefinedClass(value) { return getTypeName(value) === "class"; }

/**
 * Checks if the specified value is a plain object (i.e., created via `{}` or `new Object()`).
 * This works reliably across realms.
 * 
 * @param {*} value - The value to check.
 * @returns {boolean} `true` if it's a plain object.
 */
export function isPlainObject(value) {
	if (__object_toString.call(value) !== "[object Object]") return false;
	const proto = Object.getPrototypeOf(value);
	return proto === Object.prototype || proto === null;
}
/**
 * Checks if the specified value is a non-wrapper object (i.e., not a function, not null, not a boxed primitive).
 * Includes objects like arrays, plain objects, Date, RegExp, etc.
 *
 * @param {*} value - The value to check.
 * @returns {boolean} `true` if it's a real object, excluding primitive wrappers and functions.
 */
export function isObject(value) {
	if (value === null || typeof value !== "object" || Array.isArray(value)) 
    return false;

	// Exclude known boxed primitive wrappers
	const tag = __object_toString.call(value);
	return !["[object String]", "[object Number]", "[object Boolean]", "[object BigInt]", "[object Symbol]", "[object Function]"].includes(tag);
}

/**
 * Checks if the specified value is instance of Date.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is instance of Date.
 */
export function isDate(value) { return getTypeName(value) === "Date";  /* cross realm safe. instanceof is NOT cross realm safe! */}

/**
 * Checks if the specified value is an array.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is an array.
 */
export function isArray(value) { return Array.isArray(value); }

/**
 * Checks if the specified value is of Error type.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is of Error type.
 */
export function isError(value) { 
	return value != null 
		&&
		__object_toString.call(value) === "[object Error]"; // Works on cross realm
		/* Note: 
		*   obj instanceof Error; 
		* works only in same realm (same frame!)
		*/
}

/**
 * Determines whether the specified value is one of the primitive types: string, number, bigint, boolean and symbol (including wrapper type over primitive)
 * This function treats primitives any of the following: string, number, boolean, symbol, bigint and their wrapper String, Number, Boolean.
 * @param {*} value
 * @returns {boolean} - true if the specified value is one of the primitive types: string, number, bigint, boolean and symbol (including wrapper type over primitive)
 */
export function isPrimitive(value) {
	return value != null && __primitives.includes(getTypeName(value));
}

/**
 * Checks if the value is an integer (number or bigint), optionally applying a custom predicate.
 * @param {*} value - The value to test.
 * @param {function(number):boolean} [additionalPredicate = null] - Additional test to apply to the value.
 * @returns {boolean} - `true` if the value is integral number and passed the test against the additional predicate (if it was specified) .
 */
export function isIntegral(value, additionalPredicate = null) { 
	const isIntegral =
		isBigInt(value) ||
		(isNumber(value) && Math.floor(value) === value);
	return isIntegral && (additionalPredicate == null || additionalPredicate(value) === true); 
}

/**
 * Checks if the specified value is iterable (has a `[Symbol.iterator]` method).
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is iterable (has a `[Symbol.iterator]` method).
 */
export function isIterable(value) {
	return isFunctionOrGeneratorFunction(value?.[Symbol.iterator]);
}

/**
 * Checks if the specified value is a function or a generator function.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is a function or a generator function.
 */
export function isFunctionOrGeneratorFunction(value) {
	const type = typeof value;
	const tag = __object_toString.call(value);
	return (type === "object" || type === "function") && (tag === "[object Function]" || tag === "[object GeneratorFunction]");
}

/**
 * Checks if the specified value is a generator function.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is a generator function.
 */
export function isGeneratorFunction(value) {
	return __object_toString.call(value) === "[object GeneratorFunction]";
}

/**
 * Checks if the specified value is an asynchronous function.
 * @param {*} value - The value to test.
 * @returns {boolean} - `true` if the specified value is an asynchronous function.
 */
export function isAsyncFunction(value) {
	return __object_toString.call(value) === "[object AsyncFunction]";
}

/**
 * Parses common values like string "true" → boolean true, "42.0" → number, etc.
 * @param {*} value
 * @returns {InferDataTypeResult}
 */
export function inferDataType(value) {
	const type = getTypeName(value);
	if (type !== "string") {
		return new InferDataTypeResult(value, value, type);
	}
	const trimmed = value.trim();
	if (trimmed === "true" || trimmed === "false") {
		return new InferDataTypeResult(value, trimmed === "true", "boolean");
	}

	const parsed = parseFloat(trimmed);
	if (!isNaN(parsed)) {
		// normalize both to Number→String comparison to avoid fragile regex cleanup
		const parsedStr = parsed.toString();
		const inputNumeric = Number(trimmed).toString();

		if (parsedStr === inputNumeric) {
			return new InferDataTypeResult(value, parsed, "number");
		}
	}
	return new InferDataTypeResult(value, value, "string");
}

/**
 * Parses a string to boolean. If the input is already boolean, returns it.
 * If it's "true" or "false" (case insensitive), converts accordingly.
 * @param {*} value - The value to parse.
 * @returns {boolean|undefined} - 'true' if the specified value is a string trimmed to literal 'true' (case insensitive) or `false` if it is trimmed to literal 'false' (case insensitive).
 */
export function parseBool(value) {
	if (isBoolean(value)) 
		return value;

	if (isString(value)) {
		const lower = value.trim().toLowerCase();
		if (lower === "true") return true;
		if (lower === "false") return false;
	}
 	return undefined;
}

/**
 * Represents the result of inferred data type conversion.
 */
export class InferDataTypeResult {
	/**
	 * @param {*} originalValue
	 * @param {*} parsedValue
	 * @param {string|null} type
	 */
	constructor(originalValue, parsedValue, type) {
	/** @type {*} */
	this.originalValue = originalValue;
	/** @type {*} */
	this.parsedValue = parsedValue;
	/** @type {string|null} */
	this.type = type;
	}
}

/**
 * Checks if the specified value is a RegExp.
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the value is a RegExp.
 */
export function isRegExp(value) {
	return getTypeName(value) === "RegExp";
}

/**
 * Checks if the specified value is a Map.
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the value is a Map.
 */
export function isMap(value) {
	return getTypeName(value) === "Map";
}

/**
 * Checks if the specified value is a Set.
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the value is a Set.
 */
export function isSet(value) {
	return getTypeName(value) === "Set";
}

/**
 * Checks if the specified value is a WeakMap.
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the value is a WeakMap.
 */
export function isWeakMap(value) {
	return getTypeName(value) === "WeakMap";
}

/**
 * Checks if the specified value is a WeakSet.
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the value is a WeakSet.
 */
export function isWeakSet(value) {
	return getTypeName(value) === "WeakSet";
}

/**
 * Checks if the specified value is a Promise.
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the value is a Promise.
 */
export function isPromise(value) {
	return getTypeName(value) === "Promise";
}

/**
 * Checks if the specified value is "empty".
 * The following are considered empty:
 * - null, undefined
 * - empty string: ""
 * - empty array: []
 * - empty object: {}
 * - empty Map or Set 
 * - Note: The implementation avoids treating numbers or booleans as "empty". Only typical "container-like" values and nullish types are evaluated.
 * 
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the value is considered empty.
 */
export function isEmpty(value) {
	if (value == null) return true; // null or undefined
	
	if (isString(value) || Array.isArray(value)) {
		return value.length === 0;
	}

	if (isMap(value) || isSet(value)) {
		return value.size === 0;
	}

	if (isPlainObject(value)) {
		return Object.keys(value).length === 0;
	}

	return false;
}

/**
 * Checks if the specified value is not empty.
 * See `isEmpty` for full empty definition.
 * @param {*} value - The value to test.
 * @returns {boolean} `true` if the value is not considered empty.
 */
export function isNotEmpty(value) {
	return !isEmpty(value);
}