typeanalyser
Version:
Provides type detection and analysis for ANY Javascript object (including custom types) in any environment, even where native operators falter.
417 lines (397 loc) • 21.7 kB
JavaScript
/*******************************************************************************************************
* Type-Analyser
* MIT License
* For full license details see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
* Copyright(c) 2023 Owen Cullum <dev@metagu.com>
*******************************************************************************************************/
/**
* Accurately identifies the type of all Javascript objects not just the primitive types and it's much more
* useful than the built-in javascript 'typeof' operator. It provides the same core functionality but also
* has the following advantages. It;
*
* - returns the correct type for null, Array, **all** ES6 / ES2020 types and custom types ( E.g. your classes ).
*
* - works correctly with types correctly simulated via Polyfills (E.g. Symbol via Babel )
*
* - distinquishes between different types of functions( regular, async, generator, arrow )
*
* - correctly identifies types retieved from Iframes and Worker threads where passing of those types is supported.
*
*
* SPECIAL CASES - toString( ) Override and [Symbol.toStringTag]
* There are some special cases where the returned 'type' might not align with your expectations:
*
* Objects with toString() Overridden: If the built-in `toString()` method is overridden by custom code, 'unknown'
* will be returned for the type. You can still use `toString()` to retrieve
* the value set by the custom code.
*
* Custom Classes with [Symbol.toStringTag]: If custom classes have `[Symbol.toStringTag]` set, the returned value will be
* the class name rather than the toStringTag value. This design is intentional.
* If you want to retrieve the custom tag, [Symbol.toStringTag] will still return
* whatever value was set.
*
* Object Types with [Symbol.toStringTag]: Actual Object types with [Symbol.toStringTag] set will have that value
* returned for the type of the object.
*
* Rationale
* The goal here is to reveal the intrinsic underlying 'type' of an object. For built-in types and custom objects, using
* [Symbol.toStringTag] doesn't alter that. However, we consider actual Object types an exception and return the
* [Symbol.toStringTag] value. This is because JavaScript's type system returns 'object' for all Objects, making it
* impossible to distinguish one type of custom Object from another without using [Symbol.toStringTag].
*
* @param {*} obj - The object to get the type of.
*
* @returns a string representing the type of the object passed in. if a type can't be determined, the string 'unknown'
* will be returned. The following types will be in lower case as per the built-in javascript typeof operator:
* 'string', 'number', 'boolean', 'undefined', 'symbol', 'function', 'object', 'bigint'. All other built-in
* types will be recognised and returned in CamelCase format as per the Javascript standard: E.g. 'Array',
* 'Date', 'Error', 'RegExp', 'URL' etc.
*/
function getTypeOf(obj) {
if (obj === null) return 'null';
var typeStr = typeof obj;
var basicType = typeStr;
if (typeStr !== 'object' && typeStr !== 'function' && typeStr !== 'string') {
return typeStr;
}
// if the object has the toString method overridden, then we can't accurately determine it's type
if (obj.hasOwnProperty("toString")) {
return "unknown";
}
// special case to handle old ES5 Symbol Polyfills which should always have a value of Symbol(something)
if (typeStr === 'string') {
return obj.valueOf && obj.valueOf().toString().slice(0, 6) === 'Symbol' ? 'symbol' : 'string';
}
// get a more detailed string representation of the object's type
// slice(8, -1) removes the constant '[object' and the last ']' parts of the string
typeStr = Object.prototype.toString.call(obj).slice(8, -1);
if (!obj.prototype && typeStr === 'Function') {
return 'ArrowFunction';
}
if (typeStr === 'Object' || typeStr === 'Function') {
typeStr = typeStr.toLowerCase();
}
if (basicType === 'object' && obj.constructor) {
var es6ClassName = obj.constructor.name;
if (es6ClassName !== 'Object') {
return typeStr = es6ClassName;
}
}
return typeStr;
}
/**
* Performs type introspection and returns detailed type information about the object passed in. This function returns
* useful information about all types including ES6 / EES2020 and customs types ( E.g. Your classes ).
*
* For the returned 'type' field, the same special cases involving the use of `toString( )` override and `[Symbol.toStringTag]`
* apply as per the **`getTypeOf`** function above and the same rationale applies. The goal is for the 'type' field
* to reveal the intrinsic underlying 'type' of an object. For built-in types and custom objects, using `[Symbol.toStringTag]`
* doesn't alter that by design here. However, we consider actual `Object` types an exception and return the
* `[Symbol.toStringTag]` value. This is because JavaScript's type system returns 'object' for all Objects, making it
* impossible to distinguish one type of custom Object from another without using `[Symbol.toStringTag]`.
*
* @param {*} obj - the object to get type information about.
*
* @param {*} showFullPrototypeChain - Optional. if true (the default value), the full javascript inheritance prototype chain will be included
* in the returned object.
* if false, The final 'Object' will be removed from the chain and also only
* chains longer than 1 will be included as in this case the chain will be just
* have a single value the same as the Type field which is not very useful.
*
* @returns - an object containing the following fields (Default values are shown):
* { Type: "null", // A string representation of the exact input type. This is set for all types not just primitives.
* The following types will be in lower case as per the built-in javascript typeof operator:
* 'string', 'number', 'boolean', 'undefined', 'symbol', 'function', 'object', 'bigint'.
* A Null object will be detected as 'null'. All other built-in types will be recognised and returned
* in CamelCase Format as per the Javascirpt standard: E.g. 'Array', 'Date', 'Error', 'RegExp', 'URL' etc
* if a type can't be determined, then 'unknown' will be returned.
* ReferenceVariable: "", // A string representation of the reference variable, if any, that points to the input object.
* hasCustomConstructor: false, // true if the input object has a it's own custom constructor, false otherwise.
* prototypeChainString : "", // a string representation of the Javascript inheritance prototype chain of the input object. Objects
* in the chain are separated by ' -> '. E.g. 'Child -> Parent -> Object'.
* prototypeChain : null, // an array containing the javascript inheritance prototype chain of the input object passed.
* };
*/
function getTypeDetails(obj, showFullPrototypeChain) {
showFullPrototypeChain = showFullPrototypeChain === undefined ? true : showFullPrototypeChain;
var resultInfo = {
Type: "null",
ReferenceVariable: "",
hasCustomConstructor: false,
prototypeChainString: "",
prototypeChain: null
};
if (obj === null) {
return resultInfo;
}
if (obj === undefined) {
resultInfo.Type = 'undefined';
return resultInfo;
}
var coreTypes = ['String', 'Number', 'Boolean', 'Undefined', 'Null', 'Symbol', 'Function', 'Object', 'BigInt'];
var typeStr = Object.prototype.toString.call(obj).slice(8, -1);
if (coreTypes.includes(typeStr)) {
typeStr = typeStr.toLowerCase();
}
if (!obj.prototype && typeStr === 'function') {
typeStr = 'ArrowFunction';
}
var es6ClassName;
if (typeof obj === 'object' && obj.constructor) {
es6ClassName = obj.constructor.name;
if (es6ClassName !== 'Object') {
typeStr = es6ClassName;
resultInfo.hasCustomConstructor = true;
}
}
// if the object has the toString method overridden, then we can't accurately determine it's type
if (obj.hasOwnProperty("toString")) {
typeStr = "unknownn";
}
var pChain = getPrototypeChain(obj);
if (showFullPrototypeChain) {
resultInfo.prototypeChain = pChain;
resultInfo.prototypeChainString = pChain.join(' -> ');
} else {
// remove the last element which is always Object and only show chains longer than 1
pChain.pop();
if (pChain.length > 1) {
resultInfo.prototypeChain = pChain;
resultInfo.prototypeChainString = pChain.join(' -> ');
}
}
resultInfo.Type = typeStr;
if (obj.name && !es6ClassName) {
if (Object.prototype.hasOwnProperty.call(obj, 'name')) {
resultInfo.ReferenceVariable = obj.name;
}
}
return resultInfo;
}
/**
* get the full prototype chain of the object passed in.
* @param {*} obj
* @returns an array containing the names of the objects in the prototype chain of the object passed in.
*/
function getPrototypeChain(obj) {
var proto = Object.getPrototypeOf(obj);
var chain = [];
while (proto != null) {
chain.push(proto.constructor.name);
proto = Object.getPrototypeOf(proto);
}
return chain;
}
function enhancedTypeOf() {
console.warn('Warning: enhancedTypeOf is deprecated. Please use getTypeOf.');
return getTypeOf.apply(this, arguments);
}
/*******************************************************************************************************
* Type-Analyser
* MIT License
* For full license details see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
* Copyright(c) 2023 Owen Cullum <dev@metagu.com>
*******************************************************************************************************/
/**
* Returns information about the 'sub-type' of number passed in. Unlike the built-in javascript isNaN( ) function,
* this returns useful information about the 'type' of number passed in. E.g. it will return 'infinity' for Infinity
* or it will return 'unsafeNumber' for a number that is too large to be a safe integer. It will return 'NaN'
* for BigInt and Symbol types unlike the built-in isNaN( ) function which throws a type error for these types.
*
* Also note
* 1. NaN returns 'NaN' as that is the 'number' sub-type that it actually is.
* 2. Booleans also returns 'NaN' because even though javascript coerces them into 0 or 1, relying on this
* behaviour is not good practice and can lead to bugs.
* 3. Number Objects (created via new Number(xxx)) return 'numberObject'. Some JavaScript methods
* and functions behave differently when they receive an object instead of a primitive value.
* E.g. when comparing with ===, new Number(10) !== 10.
*
* @param {*} obj the object to get number type information about.
*
* @param {*} acceptStringNumbers - Optional. if true (the default value), then if a string is passed in, it
* will be converted to a number and the that will tested. Strings
* are not coerceced to numbers if they do not represent a valid number. E.g '34.345abchs'
* will not be converted to a number and will return 'NaN'. But '34.345' will be converted
* to a number and will return 'safeFloat'. Strings representing Hex numbers also work - E.g. 0xFF.
* (Note - the built in javascript parseFloat() function can be used before calling this
* function to force coercing. E.g. it will convert '34.345abchs' to 34.345).
* if acceptStringNumbers is false then when a string is passed in, it will never be
* converted to a number and 'NaN' will be returned.
*
* @returns - a string representing the sub-type of the number passed in. The possible values are:
* 'bigint', 'NaN' , 'infinity', '-infinity', 'safeInteger', 'unsafeNumber' 'safeFloat' and 'numberObject'
*/
function getNumTypeOf(obj, acceptStringNumbers) {
acceptStringNumbers = acceptStringNumbers === undefined ? true : acceptStringNumbers;
var typeStr = typeof obj;
if (typeStr === 'string' && acceptStringNumbers) {
obj = Number(obj);
typeStr = typeof obj;
}
if (typeStr === 'bigint') {
return 'bigint';
}
if (typeStr !== 'number') {
return obj && Object.getPrototypeOf(obj) === Number.prototype ? 'numberObject' : 'NaN';
}
if (isNaN(obj)) {
return 'NaN';
}
if (!Number.isFinite(obj)) {
return obj < 0 ? '-infinity' : 'infinity';
}
if (Number.isSafeInteger(obj)) {
return 'safeInteger';
} else {
return Number.isSafeInteger(Number(obj.toFixed())) ? 'safeFloat' : 'unsafeNumber';
}
}
/**
* Tests to see if the number passed in is safe to use in a calculation. This is useful because Javascript
* has a number of different types of numbers and some of them are not safe to use in calculations. E.g.
* BigInts are not safe to use in calculations with regular numbers. Also, numbers that are too large to be
* safe integers are not safe to use in calculations.
*
* Note also
* 1. NaN returns false as it is not a safe number to use in calculations.
* 2. Booleans also returns false because even though javascript coerces them into 0 or 1, relying on this
* behaviour is not good practice and can lead to bugs.
* 3. Number Objects (created via new Number(xxx)) return false. Some JavaScript methods
* and functions behave differently when they receive an object instead of a primitive value.
* E.g. when comparing with ===, new Number(10) !== 10.
*
* @param {*} obj - The object to check if it is a safe number.
*
* @param {*} acceptStringNumbers - Optional. if true (the default value), then if a string is passed in, it
* will be converted to a number and it's type will tested for safe use. Strings
* are not coerceced to numbers if they do not represent a valid number. E.g '34.345abchs'
* will not be converted to a number and will return false. But '34.345' will be converted
* to a number and will return true. String representing Hex numbers also work - E.g. 0xFF.
* (Note - the built in javascript parseFloat() function can be used before calling this
* function to force coercing. E.g. it will convert '34.345abchs' to 34.345).
* if acceptStringNumbers is false then when a string is passed in, it will never be
* converted to a number and false will be returned
*
* @returns - true if the number passed in is safe to use in a calculation, false otherwise.
*/
function isSafeNum(obj, acceptStringNumbers) {
acceptStringNumbers = acceptStringNumbers === undefined ? true : acceptStringNumbers;
var typeStr = getNumTypeOf(obj, acceptStringNumbers);
return typeStr === 'safeInteger' || typeStr === 'safeFloat' ? true : false;
}
function isSafeNumber() {
console.warn('Warning: isSafeNumber is deprecated. Please use isSafeNum.');
return isSafeNum.apply(this, arguments);
}
function typeOfNumber() {
console.warn('Warning: typeOfNumber is deprecated. Please use getNumTypeOf.');
return getNumTypeOf.apply(this, arguments);
}
/*******************************************************************************************************
* Type-Analyser
* MIT License
* For full license details see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
* Copyright(c) 2023 Owen Cullum <dev@metagu.com>
*******************************************************************************************************/
/**
* Checks if an object is JSON serializable. This is a recursive function that will check all properties of an object.
*
* @param {*} obj - the object to test
*
* @param {*} acceptFormatLoss - Optional. if false (the default), only return true for types that can be serializaed without problems.
*
* If this parmeter is true then this function also returns true for types where no data is lost but
* the format is changed and can be easily converted back when de-serializing. E.g. 'Date', 'URL',
* 'URLsearchparams' are converted to strings when serializing to JSON, so New Date( stringValue ) or
* new URL( stringValue ) etc can be used to convert back.
*
* Typed arrays are converted to regular arrays when serializing to JSON so iterating over the results
* of the parsed JSON element, adding to an array and then new TypedArray( array ) can be used to convert back.
*
* @param {*} visitedObjects - Used internally to detect circular references. Do not pass this parameter.
*
* @returns true if the object is JSON serializable WITHOUT a loss of data, false otherwise. Note that if 'acceptFormatLoss' is set
* then this function returns true if during JSON serialization there's no actual data loss but the format may be changed.
*/
function isJSONSerializable(obj, acceptFormatLoss, visitedObjects) {
acceptFormatLoss = acceptFormatLoss === undefined ? false : acceptFormatLoss;
visitedObjects = visitedObjects === undefined ? new Set() : visitedObjects;
var validJSONTypes = ['string', 'number', 'boolean', 'undefined', 'null'];
// types where no data is lost but there is a change in data format when serializing
var lossyValidJSONTypes = ['Date', 'URL', 'URLSearchParams', 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', 'BigInt64Array', 'BigUint64Array'];
if (acceptFormatLoss) {
validJSONTypes.push(...lossyValidJSONTypes);
}
var type = getTypeOf(obj);
if (validJSONTypes.includes(type)) return true;
if (type === 'object' || type === 'Array') {
// check for circular references
if (visitedObjects.has(obj)) {
return false;
}
visitedObjects.add(obj);
// Non integer array properties cannot be JSON serialized as data will be lost when serializing to JSON
if (type === 'Array') {
var totalPropertyCount = 0;
for (var key in obj) {
totalPropertyCount++;
}
if (totalPropertyCount > obj.length) return false;
}
// recursively check all properties
for (var key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (!isJSONSerializable(obj[key], acceptFormatLoss, visitedObjects)) return false;
}
}
return true;
}
// For functions, Symbols and other non-JSON data types NOT in validJSONTypes
return false;
}
/**
* Checks if an object has a circular reference. This is a recursive function that will check all properties of an object.
* It works for ALL types of objects including custom and ES6 classes and is particularily useful for debugging.
*
* However, note:
* 1. It checks object properties (i.e., their state) and does not check for circular references in methods or object prototypes
* 2. It won't catch circular references in dynmically created properties (i.e., created when methods are called)
* 3. If a custom or ES6 class overrides the default behavior of for...in or Object.keys, there may be problems
*
* @param {*} obj - the object to test
*
* @param {*} visitedObjects - Used internally to detect circular references. Do not pass this parameter.
*
* @returns true if the object has a circular reference, false otherwise.
*/
function hasCircularRef(obj, visitedObjects) {
visitedObjects = visitedObjects === undefined ? new WeakSet() : visitedObjects;
if (typeof obj !== 'object' || obj === null) {
return false;
}
if (visitedObjects.has(obj)) {
return true;
}
visitedObjects.add(obj);
for (var key in obj) {
if (hasCircularRef(obj[key], visitedObjects)) {
return true;
}
}
return false;
}
function hasCircularReference() {
console.warn('Warning: hasCircularReference is deprecated. Please use hasCircularRef.');
return hasCircularRef.apply(this, arguments);
}
exports.enhancedTypeOf = enhancedTypeOf;
exports.getNumTypeOf = getNumTypeOf;
exports.getTypeDetails = getTypeDetails;
exports.getTypeOf = getTypeOf;
exports.hasCircularRef = hasCircularRef;
exports.hasCircularReference = hasCircularReference;
exports.isJSONSerializable = isJSONSerializable;
exports.isSafeNum = isSafeNum;
exports.isSafeNumber = isSafeNumber;
exports.typeOfNumber = typeOfNumber;
;