Last active
November 17, 2022 02:15
-
-
Save patrickkunka/893f9d4ba8180fa19ff9994f521d0d73 to your computer and use it in GitHub Desktop.
Configuration Patterns
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
/** | |
* Configuration #1: Basic Configuration Class | |
* | |
* A basic example utilising a sealed instance of a configuration | |
* class, with user-provided options merged in upon instantation | |
* of the implementation. | |
*/ | |
class Config { | |
constructor() { | |
this.option1 = false; | |
this.option2 = true; | |
this.option3 = 300; | |
this.option4 = 'ease-in-out'; | |
Object.seal(this); | |
} | |
} | |
class Implementation { | |
constructor(input, config={}) { | |
this.input = null; | |
this.config = new Config(); | |
Object.seal(this); | |
this.init(init, config); | |
} | |
/** | |
* @param {HTMLElement} input | |
* @param {object} config | |
* @return {void} | |
*/ | |
init(input, config) { | |
// In the interest of keeping constructor functions | |
// clean and schema-like, all initialisation functionality | |
// is moved into this method. Additional validation and | |
// error-checking could be added here. | |
this.input = input; | |
// Merge consumer-provided config options over the | |
// top of the `Config` instance. Because the object is sealed, | |
// any invalid config options will cause a TypeError to | |
// be thrown. | |
Object.assign(this.config, config); | |
} | |
} |
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
/** | |
* Configuration #2: Better Error Handling | |
* | |
* In this example, a try/catch block is added to the `Object.assign()` call, | |
* attached to a custom error handler call to provide a more helpful error | |
* message. | |
*/ | |
class Implementation { | |
constructor(input, config={}) { | |
this.input = null; | |
this.config = new Config(); | |
Object.seal(this); | |
this.init(init, config); | |
} | |
/** | |
* @param {HTMLElement} input | |
* @param {object} config | |
* @return {void} | |
*/ | |
init(input, config) { | |
this.input = input; | |
try { | |
Object.assign(this.config, config); | |
} catch(err) { | |
handleMergeError(err, this.config); | |
} | |
} | |
} | |
/** | |
* A generic function to handle `seal`-related TypeErrors when merging | |
* one object's properties into another. | |
* | |
* @param {Error} err | |
* @param {object} target | |
*/ | |
function handleMergeError(err, target) { | |
// This expression will match the following error messages: | |
// - 'Cannot add property foo, object is not extensible' (V8) | |
// - 'can't define property "foo": Object is not extensible' (Rhino) | |
const re = /property "?(\w*)"?[,:] object/i; | |
let matches = null; | |
// If error is not a `TypeError`, or does not match the expression above, rethrow it | |
if (!(err instanceof TypeError) || !(matches = re.exec(err.message))) throw err; | |
const keys = Reflect.ownKeys(target); | |
const offender = matches[1].toLowerCase(); | |
// Iterate trough all keys in the target object, checking | |
// for a "best match" based on most number of like characters | |
// insensitive of case | |
const bestMatch = keys.reduce((bestMatch, key) => { | |
let charIndex = 0; | |
while ( | |
charIndex < offender.length && | |
offender.charAt(charIndex) === key.charAt(charIndex).toLowerCase() | |
) charIndex++; | |
return charIndex > bestMatch.length ? key : bestMatch; | |
}, ''); | |
// If no "best match" was found, do not add a suggestions | |
const suggestion = bestMatch ? `. Did you mean "${bestMatch}"?` : ''; | |
throw new TypeError(`Invalid configuration option "${matches[1]}"${suggestion}`); | |
} |
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
/** | |
* Configuration #3: Complex Configuration | |
* | |
* In this example, the config object has been refactored | |
* into multiple distinct "domains", organised by functionality. | |
* | |
* A recursive extend/merge is now required, so `Object.assign()` | |
* is no longer used. | |
*/ | |
class ConfigDomain1 { | |
constructor() { | |
this.option1 = false; | |
this.option2 = true; | |
Object.seal(this); | |
} | |
} | |
class ConfigDomain2 { | |
constructor() { | |
this.option3 = 300; | |
this.option4 = 'ease-in-out'; | |
Object.seal(this); | |
} | |
} | |
class ConfigRoot { | |
constructor() { | |
this.domain1 = new ConfigDomain1(); | |
this.domain2 = new ConfigDomain2(); | |
Object.seal(this); | |
} | |
} | |
class Implementation { | |
constructor(input, config={}) { | |
this.input = null; | |
this.config = new ConfigRoot(); | |
Object.seal(this); | |
this.init(init, config); | |
} | |
/** | |
* @param {HTMLElement} input | |
* @param {object} config | |
* @return {void} | |
*/ | |
init(input, config) { | |
this.input = input; | |
merge(this.config, config, handleMergeError); | |
} | |
} | |
/** | |
* A custom recursive "merge" implementation. While this could be | |
* swapped out with similar functions from jQuery, underscore, | |
* lodash etc, this function allows for custom error handling | |
* to be plugged in at each level as per the previous example. | |
* | |
* @param {object} target | |
* @param {object} source | |
* @param {function} [errorHandler=null] | |
* @return {object} | |
*/ | |
function merge(target, source, errorHandler=null) { | |
let sourceKeys = []; | |
if (!target || typeof target !== 'object') { | |
throw new TypeError('Target must be a valid object'); | |
} | |
if (Array.isArray(source)) { | |
for (let i = 0; i < source.length; i++) { | |
sourceKeys.push(i); | |
} | |
} else if (source) { | |
sourceKeys = Reflect.keys(source); | |
} | |
// Iterate through all keys of the source object | |
for (let i = 0; i < sourceKeys.length; i++) { | |
const key = sourceKeys[i]; | |
const descriptor = Object.getOwnPropertyDescriptor(source, key); | |
// Skip non-enumerable getters | |
if (typeof descriptor.get === 'function' && !descriptor.enumerable) continue; | |
if (typeof source[key] !== 'object' || source[key] === null) { | |
// All non-object primitives or nulls | |
try { | |
target[key] = source[key]; | |
} catch (err) { | |
// Catch and handle assignment errors | |
if (typeof errorHandler !== 'function') throw err; | |
errorHandler(err, target); | |
} | |
} else if (Array.isArray(source[key])) { | |
// Arrays | |
if (!target[key]) { | |
target[key] = []; | |
} | |
merge(target[key], source[key], errorHandler); | |
} else { | |
// Objects | |
if (!target[key]) { | |
target[key] = {}; | |
} | |
merge(target[key], source[key], errorHandler); | |
} | |
} | |
return target; | |
} |
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
/** | |
* Configuration #4: Better Validation | |
* | |
* In this example, ES5 `Object.defineProperties()` is combined | |
* with two helper functions utilising ES5 getter/setter functions | |
* to provide stricter and more helpful validation of the config object. | |
*/ | |
class ConfigRoot { | |
constructor() { | |
Object.defineProperties(this, { | |
option1: typedProp('option1', Boolean, false), | |
option2: typedProp('option2', Boolean, true), | |
option3: typedProp('option3', Number, 300), | |
option4: enumProp('option4', ['ease', 'ease-in', 'ease-out', 'ease-in-out']), | |
}); | |
Object.seal(this); | |
} | |
} | |
/** | |
* Returns a property descriptor defining a strictly-typed | |
* property. | |
* | |
* @param {string} key The name of the property | |
* @param {function} type The type of the property | |
* @param {*} init A value to initialise the property with | |
* @param {function} [callback=null] An optional callback to be invoked whenever the property is set | |
* @return {object} A property descriptor | |
*/ | |
function typedProp(key, type, init, callback=null) { | |
let _value = init; | |
return { | |
get: () => _value, | |
set(value) { | |
const typeOf = typeof type(); | |
if (typeof value !== typeOf) { | |
throw new TypeError(`Value "${value.toString()}" on property "${key}" is not a ${typeOf}`); | |
} | |
if (typeof callback === 'function') callback(value); | |
_value = value; | |
} | |
}; | |
} | |
/** | |
* Returns a property descriptor definining a property | |
* with a finite set of possible values, typically | |
* strings or symbols. | |
* | |
* @param {string} key The name of the property | |
* @param {Array.<string>} values An an array of possible values for the property | |
* @param {function} [callback=null] An optional callback to be invoked when the property is set | |
* @return {object} A property descriptor | |
*/ | |
function enumProp(key, values, callback=null) { | |
// Use the first value in the array as the default | |
let _value = values[0]; | |
return { | |
get: () => _value, | |
set(value) { | |
if (values.indexOf(value) < 0) { | |
throw new RangeError(`Value "${value.toString()}" not allowed for property "${key}"`); | |
} | |
if (typeof callback === 'function') callback(value); | |
_value = value; | |
} | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment