Skip to content

Instantly share code, notes, and snippets.

@patrickkunka
Last active November 17, 2022 02:15
Show Gist options
  • Save patrickkunka/893f9d4ba8180fa19ff9994f521d0d73 to your computer and use it in GitHub Desktop.
Save patrickkunka/893f9d4ba8180fa19ff9994f521d0d73 to your computer and use it in GitHub Desktop.
Configuration Patterns
/**
* 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);
}
}
/**
* 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}`);
}
/**
* 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;
}
/**
* 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