UNPKG

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
'use strict'; /******************************************************************************************************* * 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;