Skip to content

Instantly share code, notes, and snippets.

@dschnare
Last active November 18, 2019 04:01
Show Gist options
  • Save dschnare/b5a2009d83a5e1803842a93f9c751498 to your computer and use it in GitHub Desktop.
Save dschnare/b5a2009d83a5e1803842a93f9c751498 to your computer and use it in GitHub Desktop.
Object hierarchy inspired by Smalltalk and OOD
/**
* 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