Last active
November 18, 2019 04:01
-
-
Save dschnare/b5a2009d83a5e1803842a93f9c751498 to your computer and use it in GitHub Desktop.
Object hierarchy inspired by Smalltalk and OOD
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Business Object class object that acts as the root of an object hierarchy. | |
* Creates object instances that have methods marked as read-only and the object | |
* sealed, meaning no new properties can be added and behaviour cannot be changed | |
* without extending the class object (i.e. tamperproof). | |
* | |
* @example | |
* const Box = BObject.subClass({ | |
* className: 'Box', | |
* new ($base, width, height) { | |
* const base = $base() | |
* return { | |
* get width () { return width }, | |
* get height () { return height }, | |
* toString () { return `Box width:${width} height:${height}` } | |
* } | |
* } | |
* }) | |
* console.log(Box.new(10, 25).toString()) | |
* @type {ClassObject<null, { isInstanceOf: (Cls) => boolean, errorObject: <T>(message: string, props?: T) => Error & T, error: (message: string, props?) => never}, any[]>} | |
*/ | |
const BObject = (function () { | |
'use strict' | |
const CatchPropertyAccessFactory = (function () { | |
// Have to test for Reflect since it was introduced in Firefox browswers | |
// AFTER the Proxy API was introduced | |
const setter = (typeof Reflect === 'object' && Reflect) | |
? Reflect.set | |
: (target, prop, value) => { | |
target[prop] = value | |
return true | |
} | |
const getter = (typeof Reflect === 'object' && Reflect) | |
? Reflect.get | |
: (target, prop) => target[prop] | |
const isReadable = (descriptors, prop) => { | |
return !!descriptors[prop].get || 'value' in descriptors[prop] | |
} | |
const isWritable = (descriptors, prop) => { | |
return !!descriptors[prop].set || descriptors[prop].writable | |
} | |
/** | |
* Wraps the object in a proxy that will catch all property access and throw a | |
* TypeError when: | |
* | |
* - A property is being accessed and it does not exist | |
* - A writeonly property is being set | |
* - A readonly property is being read | |
* | |
* This will help developers catch property name spelling mistakes and missuse. | |
* | |
* @template {{}} T | |
* @param {T} o | |
* @param {{ errorPrefix?: string }} [options] | |
* @return {T} | |
*/ | |
function F (o, { errorPrefix = '' } = {}) { | |
if (typeof Proxy === 'function') { | |
const descriptors = Object.getOwnPropertyDescriptors(o) | |
return new Proxy(o, { | |
get (target, prop, receiver) { | |
if (prop in target) { | |
if (isReadable(descriptors, prop)) { | |
// eslint-disable-next-line no-obj-calls | |
return getter(target, prop, receiver) | |
} else { | |
throw new TypeError(`${errorPrefix}Cannot read a writeonly property "${prop.toString()}"`) | |
} | |
} | |
throw new TypeError(`${errorPrefix}Unknown property access "${prop.toString()}"`) | |
}, | |
set (target, prop, value, receiver) { | |
if (prop in target) { | |
if (isWritable(descriptors, prop)) { | |
// eslint-disable-next-line no-obj-calls | |
return setter(target, prop, value, receiver) | |
} else { | |
throw new TypeError(`${errorPrefix}Cannot wirte a readonly property "${prop.toString()}"`) | |
} | |
} | |
throw new TypeError(`${errorPrefix}Unknown property access "${prop.toString()}"`) | |
} | |
}) | |
} | |
return o | |
} | |
return F | |
}()) | |
const NewFunctionFactory = (Base, Cls, theNewFunc) => { | |
return (...args) => { | |
let base = null | |
const $base = (...args) => { | |
if (base) { | |
throw new Error('The "$base" function can only be called once') | |
} else { | |
base = Base.new(...args) | |
return base | |
} | |
} | |
const props = theNewFunc.apply(Cls, [$base, ...args]) | |
if (!base) { | |
throw new Error('The "$base" function must be called once') | |
} | |
if (props === base) { | |
throw new Error('The "new" function cannot return the base instance') | |
} | |
const inst = Object.create(base) | |
const descriptors = { | |
...Object.getOwnPropertyDescriptors(props), | |
class: { value: Cls } | |
} | |
// Apply the OCP principle by making all methods read-only by redefining | |
// the method properties as non writable. | |
Object.keys(descriptors).filter(key => { | |
return descriptors[key].configurable && | |
typeof descriptors[key].value === 'function' | |
}).forEach(key => { | |
descriptors[key].writable = false | |
}) | |
Object.defineProperties(inst, descriptors) | |
return CatchPropertyAccessFactory(Object.seal(inst), { | |
errorPrefix: `${Cls.className}(instance) : ` | |
}) | |
} | |
} | |
return CatchPropertyAccessFactory(Object.freeze({ | |
className: 'BObject', | |
baseClass: null, | |
new () { | |
return CatchPropertyAccessFactory(Object.freeze({ | |
/** | |
* The class object for this instance. | |
*/ | |
class: this, | |
/** | |
* Determines if a class object is in the inheritence hierarchy of this instance. | |
*/ | |
isInstanceOf (Cls) { | |
let C = this.class | |
while (C) { | |
if (C === Cls) { | |
return true | |
} else { | |
C = C.baseClass | |
} | |
} | |
return false | |
}, | |
/** | |
* Throws an error with optional additional properties assigned to the error object thrown. | |
*/ | |
error (message, props = {}) { | |
if (typeof message !== 'string' || !message.trim()) { | |
throw new Error('Argument "message" must be a non-empty string') | |
} | |
throw this.errorObject(message, props || {}) | |
}, | |
/** | |
* Creates an error with optional additional properties assigned to the error object thrown. | |
*/ | |
errorObject (message, props = {}) { | |
if (typeof message !== 'string' || !message.trim()) { | |
throw new Error('Argument "message" must be a non-empty string') | |
} | |
message = this.class.className + '(instance) : ' + message | |
return Object.assign(new Error(message), props || {}) | |
} | |
})) | |
}, | |
define (toObj, fromObj, { configurable = null, enumerable = null, writable = null } = {}) { | |
const descriptors = Object.getOwnPropertyDescriptors(fromObj) | |
if (typeof configurable === 'boolean') { | |
Object.keys(descriptors).forEach(key => { | |
descriptors[key].configurable = configurable | |
}) | |
} | |
if (typeof enumerable === 'boolean' || typeof writable === 'boolean') { | |
Object.keys(descriptors).filter(key => { | |
return descriptors[key].configurable | |
}).forEach(key => { | |
if (typeof enumerable === 'boolean') { | |
descriptors[key].enumerable = enumerable | |
} | |
if (typeof writable === 'boolean') { | |
descriptors[key].writable = writable | |
} | |
}) | |
} | |
return Object.defineProperties(toObj, descriptors) | |
}, | |
/** | |
* Creates a new object class with the properties from this object class and | |
* the properties from 'staticProps'. The subclass object created is frozen | |
* so no property modifications or additions are allowed. The staticProps | |
* must at least contain a 'new($base, ...args)' method that is responsible | |
* for instantiating a new instance, and a `className` string property to | |
* identify the class object in errors. | |
* | |
* @example | |
* const MyClassObject = BObject.subClass({ | |
* className: 'MyClassObject', | |
* new ($base, a, b) { | |
* const base = $base() | |
* return { | |
* ...instance properties... | |
* } | |
* } | |
* }) | |
*/ | |
subClass (staticProps) { | |
if (Object(staticProps) !== staticProps) { | |
throw new Error('Argument "staticProps" must be an object') | |
} | |
if (typeof staticProps.new !== 'function') { | |
throw new Error('Argument "staticProps.new" must be a function') | |
} | |
if (typeof staticProps.className !== 'string') { | |
throw new Error('Argumnet "staticProps.className" must be a string') | |
} else if (!staticProps.className.trim()) { | |
throw new Error('Argument "staticProps.className" cannot be blank') | |
} | |
const className = staticProps.className.trim() | |
const theNewFunc = staticProps.new | |
const Base = this | |
const Cls = Object.create(Base) | |
Object.defineProperties(Cls, { | |
...Object.getOwnPropertyDescriptors(staticProps), | |
className: { value: className }, | |
baseClass: { value: Base }, | |
new: { value: NewFunctionFactory(Base, Cls, theNewFunc) }, | |
// @ts-ignore | |
subClass: { value: this.subClass }, | |
// @ts-ignore | |
define: { value: this.define } | |
}) | |
return CatchPropertyAccessFactory(Object.freeze(Cls), { | |
errorPrefix: `${className} : ` | |
}) | |
} | |
}), { errorPrefix: 'BObject : ' }) | |
}()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment