Skip to content

Instantly share code, notes, and snippets.

@nyteshade
Last active July 5, 2023 18:36
Show Gist options
  • Save nyteshade/0d04848559595d7077afae4739aeceed to your computer and use it in GitHub Desktop.
Save nyteshade/0d04848559595d7077afae4739aeceed to your computer and use it in GitHub Desktop.
Global Extensions for Classes
// Priavte database for applied extensions
const extensionDB = new Map()
// A constant defining the default set name
export const kDefaultSet = "Default JavaScript Extension Set"
// A constant defining the keys of a descriptor object used with
// Object.defineProperties and Object.defineProperty
export const kDescriptorKeys = [
'value', 'writable', 'get', 'set', 'enumerable', 'configurable',
]
/**
* A function to determine if a is an instance of b. This process is
* shallow and does not walk the prototype chain.
*
* @param {any} a any object to see if it is an instance of b
* @param {function} b a function/class to see if a is an instance of
* @returns {boolean} true if a is an instance of b, false otherwise
*/
export const isa = (a,b) => {
let typeOfA = /(\w+)]/gm.exec(({}).toString.call(a))[1]
let nameOfB = b?.name ?? b?.constructor.name ?? Object.name
return typeOfA === nameOfB
}
/**
* Takes a non-null object and determines if it is a descriptor object
*
* @param {any} object
* @returns {boolean} true if object is a descriptor, false otherwise
*/
export function isDescriptor(object) {
return kDescriptorKeys.some(key => Object.hasOwn(object, key))
}
/**
* Takes a non-null object and determines if it is a object of key-value
* pairs where the key is either a string or symbol and the value is a
* descriptor object. It does this by checking the first key-value pair
*
* @param {any} object the object to check if it is a descriptor set
* @returns {boolean} true if object is a descriptor set, false otherwise
*/
export function isDescriptorSet(object) {
const propKey = Object.keys(object)?.[0]
if (!propKey || !Object.hasOwn(object, propKey) || !object[propKey]) {
return false
}
return isDescriptor(object[propKey])
}
/**
* A class to represent a JavaScript extension. It maintains a reference to
* the original prototype descriptors and the descriptor objects of a given
* object of properties and functions to extend a class with. It provides
* methods to apply and remove the extension.
*/
export class JSExtension {
/**
* Creates a new JSExtension object
*
* @param {class/function} Class the class/function to extend
* @param {object} extensionCode an object of properties and functions to
* extend the class with
*/
constructor(Class, extensionCode) {
this.Class = Class
this.extension = isDescriptorSet(extensionCode)
? extensionCode
: Object.getOwnPropertyDescriptors(extensionCode)
this.original = Object.getOwnPropertyDescriptors(Class.prototype)
}
/**
* Applies the extension to the class. It does this using Object.defineProperties
* upon the stored Class to extend and using the stored extension object.
*/
apply() {
Object.defineProperties(this.Class.prototype, this.extension)
}
/**
* Removes the extension from the class. It does this by deleting the properties
* and functions from the prototype of the stored Class and then redefining the
* original properties and functions.
*/
remove() {
let extKeys = Object.keys(this.extension)
for (const key of extKeys) {
console.log(`deleting ${key}`)
console.log(' ', delete this.Class.prototype[key])
}
Object.defineProperties(this.Class.prototype, this.original)
}
get [Symbol.toStringTag]() { return this.constructor.name }
}
/**
* A class to represent a set of JavaScript extensions. It maintains a reference
* to a name and a map of classes to JSExtension objects. It provides methods to
* add, get, and delete JSExtension objects from the map. It also provides methods
* to apply and remove the set of JSExtension objects.
*/
export class JSExtensionSet {
/**
* Creates a new JSExtensionSet object
*
* @param {string} setName the name of the set
* @param {Map<Class,JSExtension>} mapOfJSExtensions an optionally non-null
* and non-empty Map of classes to JSExtension objects. If it is not supplied,
* a new Map is created.
*/
constructor(setName, mapOfJSExtensions = new Map()) {
this.name = setName
this.map = mapOfJSExtensions
}
/**
* Adds a JSExtension object to the map. If the JSExtension object is not
*
* @param {class} Class the class/function to extend
* @param {JSExtension} jsExtension the JSExtension object to add
* @returns {boolean} true if the JSExtension object was added, false otherwise
*/
setExtension(Class, jsExtension) {
if (!isa(Class, Function)) {
return false
}
if (!isa(jsExtension, JSExtension)) {
return false
}
this.map.set(Class, jsExtension)
return true
}
/**
* Gets the JSExtension object for the given class/function
*
* @param {class} Class the class/function to extend
* @returns {JSExtension} the JSExtension object for the given class/function
*/
getExtension(Class) {
const extension = this.map.get(Class)
if (isa(extension, JSExtension)) {
return extension
}
return null
}
/**
* Deletes the JSExtension object for the given class/function. This will
* trigger a removal of the extension first.
*
* @param {class} Class the class/function to extend
*/
deleteExtension(Class) {
this.map.get(Class).remove()
this.map.delete(Class)
}
/**
* Applies the set of JSExtension objects. This will trigger a removal of
* the current set of JSExtension objects first. It will then apply each
* JSExtension object in the set.
*/
apply() {
if (JSExtensionSet.currentSet) {
JSExtensionSet.currentSet.remove()
}
for (const [_, jsExtension] of this.map.entries()) {
jsExtension.apply()
}
JSExtensionSet.currentSet = this
}
/**
* Removes the set of JSExtension objects. This will trigger a removal of
* each JSExtension object in the set.
*/
remove() {
for (const [_, jsExtension] of this.map.entries()) {
jsExtension.remove()
}
JSExtensionSet.currentSet = null
}
/** Identifies this object type as an [object JSExtensionSet] */
get [Symbol.toStringTag]() { return this.constructor.name }
/** Static value to track the currently applied extension set */
static currentSet = null
}
/**
* A global function that creates a new JSExtension and adds it to the named
* JSExtensionSet. If the JSExtensionSet is not specified, the default set is
* used. The extension is not applied by this function call.
*
* @param {class|JSExtension} Class_orJSExt a class/function or a JSExtension object
* @param {object} extension an object of properties and functions to extend
* @param {string} toSet the name of the set to add the extension to
*/
export function AddExtension(Class_orJSExt, extension, toSet) {
toSet = toSet || kDefaultSet
if (isa(Class_orJSExt, Function) && isa(extension, Object)) {
return AddExtension(new JSExtension(Class_orJSExt, extension), null, toSet)
}
let set = extensionDB.get(toSet)
let newJSExtension = Class_orJSExt
if (!isa(set, JSExtensionSet)) {
set = new JSExtensionSet(toSet)
}
let jsExtension = set.getExtension(newJSExtension.Class)
if (!isa(jsExtension, JSExtension)) {
jsExtension = newJSExtension
}
else {
Object.apply(jsExtension.extension, newJSExtension.extension)
}
set.setExtension(jsExtension.Class, jsExtension)
extensionDB.set(toSet, set)
}
/**
* A global function that removes the extension from the named JSExtensionSet.
*
* @param {class} Class the class/function to remove the extension from
* @param {string} fromSet the name of the set to remove the extension from
* @returns {boolean} true if the extension was removed, false otherwise
*/
export function RemoveExtension(Class, fromSet) {
if (!isa(Class, Function)) {
return false
}
if (!extensionDB.has(fromSet)) {
return false
}
const extensionSet = extensionDB.get(fromSet)
extensionSet.deleteExtension(Class)
return true
}
/**
* A global function that applies the named JSExtensionSet. If the named set
* does not exist, this function does nothing.
*
* @param {string} set the name of the set to apply
* @returns {boolean} true if the set was applied, false otherwise
*/
export function ApplySet(set) {
if (!set || !extensionDB.has(set)) {
return false
}
if (JSExtensionSet.currentSet) {
JSExtensionSet.currentSet.remove()
}
const extensionSet = extensionDB.get(set)
extensionSet.apply()
return true
}
/**
* A global function that removes the named JSExtensionSet. If the named set
* does not exist, this function does nothing.
*
* @param {string} set the name of the set to remove
* @returns {boolean} true if the set was removed, false otherwise
*/
export function RemoveSet(set) {
if (!set || !extensionDB.has(set)) {
return false
}
const extensionSet = extensionDB.get(set)
extensionSet.remove()
return true
}
/**
* A global function that returns an array of the names of the JSExtensionSets
*
* @returns {Array} an array of the names of the JSExtensionSets
*/
export function GetSets() {
return Array.from(extensionDB.keys())
}
/**
* A global function that adds and applies a set of commonly used extensions
* for global classes. The set these extensions are applied to is the
* `kDefaultSet` set.
*
* This function adds the following extensions:
* - Array
* - first - returns the first element of the array (getter)
* - last - returns the last element of the array (getter)
* - filterFirst - returns the first element of the filtered array
* - filterLast - returns the last element of the filtered array
*
* @returns {function} a function that removes the default set of extensions
*/
export function AddAndApplyDefaultSet() {
AddExtension(Array, {
get first() { return this[0] },
get last() { return this[this.length - 1] },
filterFirst(...a) { return this.filter(...a).first },
filterLast(...a) { return this.filter(...a).last },
})
ApplySet(kDefaultSet)
return function() { RemoveSet(kDefaultSet) }
}
@nyteshade
Copy link
Author

nyteshade commented Jul 5, 2023

After this AddAndApplyDefaultSet() is invoked, the four specified Array extensions are added in a reversible manner.

let { AddAndApplyDefaultSet } = await import("extensions.mjs")
let RemoveAppliedExtension = AddAndApplyDefaultSet()

[1,2,3].first // 1
[1,2,3].last // 3
[1,2,3].filterFirst(e => e>2) // 3
[1,2,3].filterLast(e => e<3) // 2

RemoveAppliedExtension() // remove extension

[1,2,3].first // undefined

@nyteshade
Copy link
Author

This can be used to safely extend any class and restore it to how it was before the extension was created. Note that the capture point for the original class' prototype values occurs when the extension is created. Take heed of this and use as you'd like.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment