Last active
July 17, 2023 00:06
-
-
Save dfkaye/e48acca7e7f2b16d8f47bd5d4d78caee to your computer and use it in GitHub Desktop.
touch.js - instead of optional?.chaining, make objects with dynamic access-definable pathnames... using Proxy constructor
This file contains hidden or 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
// 14 June 2023 | |
// latest: 16 July 2023 | |
// `touch` makes objects with dynamic access-definable pathnames, | |
// for potentially deep undefined object paths in JavaScript, | |
// using the Proxy constructor. | |
// So instead of optional?.chaining, we can get a proxy as with | |
// ``` | |
// var p = touch(); | |
// ``` | |
// and define a namespace path to a deeply nested name by direct assignment, as | |
// ``` | |
// p.a.b.c.d.e.f.g = "really deep"; | |
// ``` | |
// and even serialize it to JSON... | |
// ``` | |
// JSON.stringify(p); | |
// `{"a":{"b":{"c":{"d":{"e":{"f":{"g":"really deep"}}}}}}}` | |
// Key findings during this exercise: | |
// 1. `JSON.stringify` fails to serialize proxied objects, requiring `toJSON()` | |
// definitions on each proxy. | |
// 2. supporting revocable proxies is not necessary, in part because... | |
// 3. support for an initial object param is not necessary. Or rather, such | |
// an object with any properties must be visited to convert nested objects | |
// into proxied objects. That goes beyond the scope of this exercise. | |
// 4. setting a name to a primitive value requires proxying that value as an | |
// object in order to support pseudo-assignment on any descendant proxies. | |
// 5. setting a name requires a `[Symbol.toPrimitive]` method in addition to the | |
// `toJSON` method in order to support loose equality checks for a name in | |
// the proxy path. | |
// 6. JSON serialization terminates at a primitive value, even though the proxy | |
// path supports appending an object or other value to a proxied primitive | |
// value in this example. | |
// 7. solving the param initialization loop involves calling handler.set() and | |
// handler.get() which run the proxy decoration logic... using the handler | |
// configuration outside a proxy object! | |
/*********** ignore ***************** | |
// 20 June 2023 | |
// Fixed access problem with the original implementation: | |
// we couldn't access `p.revoke` to test that it is non-existent (because get() | |
// creates it) | |
// instead we would have to test `!("revoke" in p)`. | |
// Now get() returns `undefined` if the key is 'revoke' and the key is not in | |
// the proxy. | |
// If the key is 'revoke' and the target is not the object under proxy, then | |
// `target.revoke` is deleted. | |
// | |
// 9 July 2023 | |
// Never mind. | |
// Support for revocable proxy has been revoked from this implementation. | |
// | |
/*********** ignore *****************/ | |
// 9 July 2023 | |
// Fixed the error wherein an value is assigned at a path the first time, | |
// `p.a.b.c.d = {name: "d"}` or `p.a.b.c.d = 3`, for example, and subsequent | |
// assignment at a deeper path fails, e.g., `p.a.b.c.d.e.f = 2` throws | |
// "TypeError p.a.b.c.d.e is undefined" | |
/*********** Important **************/ | |
// Fixed a serialization error related to primitive assignment and comparison. | |
// | |
// As a result, an expected value anywhere in the proxy chain should only be | |
// compared with loose equality or `==`, not strict equality. | |
// | |
// TODO: make the tests more coherent to emphasize this feature/restriction. | |
/*********** Important **************/ | |
// 10 July 2023 | |
// remove param support, avoid depth initialization nightmare. | |
// 15 July 2023 | |
// figured out depth initialization, restored param support. | |
// 15-16 July 2023 | |
// Expose value conversion methods. | |
// 16 July 2023 | |
// call handler.set() within handler.get() when key not in target. | |
/* let's go */ | |
function touch(object = {}) { | |
var handler = { | |
get(target, key, receiver) { | |
// console.log('__get__', target, key, target[key]); | |
if (!(key in target)) { | |
/* | |
var v = { | |
toJSON() { | |
return target[key]; | |
} | |
}; | |
*/ | |
// Reflect.set(target, key, new Proxy(v, handler)); | |
handler.set(target, key, {}, receiver); | |
} | |
var value = Reflect.get(target, key); | |
// 9 July 2023 | |
// Object conversion allows access below a primitive value at this key. | |
return Object(value); | |
}, | |
set(target, key, value, receiver) { | |
// console.log("__set__", target, key, value); | |
// 9 July 2023 | |
// Object conversion allows access below a primitive value at this key. | |
var v = Object(value); | |
v.toJSON = function () { | |
return value; | |
} | |
// 9 July 2023 | |
// Support coercion to primitive values with `==` comparisons. | |
v[Symbol.toPrimitive] = function () { | |
return value; | |
}; | |
// 15-16 July 2023 | |
// Expose value conversion methods. | |
v.toString = function () { | |
return "" + value; | |
}; | |
v.valueOf = function () { | |
return value; | |
}; | |
return Reflect.set(target, key, new Proxy(v, handler)); | |
} | |
}; | |
var o = Object(object); //{}; | |
var p = new Proxy(o, handler); | |
// 15 July 2023 | |
// figured out depth initialization | |
// requires depth-first assignment before upper level assignment | |
// otherwise result is infinite proxy calls. | |
~(function init(o) { | |
Object.keys(o).forEach(function(k) { | |
var v = o[k]; | |
if (v === Object(v)) { | |
init(handler.get(o, k)); | |
} | |
handler.set(o, k, v); | |
}); | |
})(o); | |
p.toJSON = function () { | |
return o; | |
} | |
return p; | |
} | |
/* test it out */ | |
console.group("defaults"); | |
(function () { | |
// 15 July 2023 | |
// restored param support | |
var p = touch({ a: {b: { c: "test" } } }); | |
console.assert(p.a.b.c.d, "should create a proxy chain"); | |
console.assert(p.a.b.c == "test", "should loosely equal test value"); | |
// 15 July 2023 | |
// NOTE the bifurcation between serializing from top to the primitive p.a.b., | |
// and from p.a.b.c to primitive p.a.b.c.d.e.f.g | |
p.a.b = 6; | |
p.a.b.c.d.e.f.g = "really deep"; | |
console.assert( | |
JSON.stringify(p) === '{"a":{"b":6}}', | |
"should serialize only p.a.b" | |
); | |
console.assert( | |
JSON.stringify(p.a.b.c) === '{"d":{"e":{"f":{"g":"really deep"}}}}', | |
"should serialize c.d.e.f.g" | |
); | |
// primitive values are proxied behind objects that need Symbol.toPrimitive | |
// methods in order to return these values, and these values are returned by | |
// loose equality or coercion. | |
var b = p.a.b = 5; | |
console.assert(b === 5, "should strictly equal 5"); | |
console.assert(p.a.b == 5, "should loosely equal 5"); | |
console.assert(p.a.b !== 5, "should be a proxy, not 5"); | |
console.assert(+(p.a.b) === 5, "should coerce to primitive (number)"); | |
console.assert((p.a.b).valueOf() === 5, "should return numeric value"); | |
console.assert((p.a.b).toString() === "5", "should return string value"); | |
var s = JSON.stringify(p); | |
console.assert(s === '{"a":{"b":5}}', "should serialize proxy chain"); | |
}).call(); | |
console.groupEnd("defaults"); | |
console.group("serialize"); | |
(function () { | |
var p = touch({ a: { name: 'a', depth: 1, b: 'b' } }); | |
// p.a = { name: 'a', depth: 1, b: 'b' }; | |
var sA = `{"a":{"name":"a","depth":1,"b":"b"}}`; | |
console.assert(JSON.stringify(p) === sA, `sA should serialize to ${sA}`); | |
p.a.b = { name: 'b', depth: 2 } | |
var sB = `{"a":{"name":"a","depth":1,"b":{"name":"b","depth":2}}}`; | |
console.assert(JSON.stringify(p) === sB, `sB should serialize to ${sB}`); | |
// In the initial attempt at param support, this errored because 'c' was | |
// undefined, meaning `p.a.b` was not in the proxied path... | |
// TypeError: Cannot read properties of undefined (reading 'd') | |
p.a.b.c.d | |
var sD = `{"a":{"name":"a","depth":1,"b":{"name":"b","depth":2,"c":{"d":{}}}}}`; | |
console.assert(JSON.stringify(p) === sD, `sD should serialize to ${sD}`); | |
p.a.b.c.d.e.f = 6; | |
var sF = `{"a":{"name":"a","depth":1,"b":{"name":"b","depth":2,"c":{"d":{"e":{"f":6}}}}}}`; | |
console.assert(JSON.stringify(p) === sF, `sF should serialize to ${sF}`); | |
}).call(); | |
console.groupEnd("serialize"); | |
console.group("assign and delete"); | |
(function () { | |
var p = touch(); | |
p.nonce = "nonce" | |
console.assert(p.nonce == "nonce", "should set 'nonce' on proxy"); | |
console.assert( | |
JSON.stringify(p) === `{"nonce":"nonce"}`, | |
"should serialize nonce property" | |
); | |
delete p.nonce; | |
console.assert(!('nonce' in p), "should remove 'nonce' from proxy"); | |
}).call(); | |
console.groupEnd("assign and delete"); | |
console.group("deep structure"); | |
(function () { | |
var p = touch(); | |
p.a.b.c.d.e.f.g = "really deep"; | |
console.assert( | |
p.a.b.c.d.e.f.g == 'really deep', | |
"should loosely equal 'really deep'" | |
); | |
var sRD = `{ | |
"a": { | |
"b": { | |
"c": { | |
"d": { | |
"e": { | |
"f": { | |
"g": "really deep" | |
} | |
} | |
} | |
} | |
} | |
} | |
}`; | |
console.assert( | |
JSON.stringify(p, null, 2) === sRD, | |
"should serialize to g: really deep" | |
); | |
p.a.b.c.d = 'deep'; | |
console.assert(p.a.b.c.d == 'deep', "should loosely equal 'deep'"); | |
console.assert( | |
JSON.stringify(p) === `{"a":{"b":{"c":{"d":"deep"}}}}`, | |
"should serialize to d: deep" | |
); | |
p.a = "shallow"; | |
console.assert(p.a == 'shallow', "should loosely equal 'shallow'"); | |
console.assert( | |
JSON.stringify(p), `{"a":"shallow"}`, | |
"should serialize to a: shallow" | |
); | |
p.a = { name: 'a', b: { name: 'b', c: { name: 'c', d: { name: 'd' }}}}; | |
console.assert(p.a.name == 'a', "should loosely equal 'a'"); | |
console.assert(p.a.b.c.d.name === 'd', "should strictly equal 'd'"); | |
p.a.b.c.d.e = {name: 'e'} | |
console.assert(p.a.b.c.d.e.name === 'e', "should strictly equal 'e'"); | |
var sE = `{ | |
"a": { | |
"name": "a", | |
"b": { | |
"name": "b", | |
"c": { | |
"name": "c", | |
"d": { | |
"name": "d", | |
"e": { | |
"name": "e" | |
} | |
} | |
} | |
} | |
} | |
}` | |
console.assert( | |
JSON.stringify(p, null, 2) === sE, | |
"should serialize to e name: e" | |
); | |
}).call(); | |
console.groupEnd("deep structure"); | |
console.group("set value as object with toPrimitive fix"); | |
(function() { | |
var p = touch(); | |
p | |
p.a.b.c | |
var d = p.a.b.c.d = { name: 'd', value: 2 }; | |
console.assert(d.name === 'd', "name should be 'd'"); | |
console.assert(d.value === 2, "value should be 2"); | |
// 9 July 2023 | |
// Fixed TypeError: p.a.b.c.d.e is undefined | |
var f = p.a.b.c.d.e.f = 2; | |
console.assert(f === 2, "f should be 2"); | |
p.a.b.c.d.value = 55; | |
var control = `{"a":{"b":{"c":{"d":{"name":"d","value":55,"e":{"f":2}}}}}}`; | |
var s1 = JSON.stringify(p); | |
console.assert(s1 === control, "should serialize to f: 2"); | |
// 9 July 2023 | |
// Fixed: can't assign 'h' because 'f' is a primitive... | |
var h = p.a.b.c.d.e.f.g.h = 3; | |
console.assert(h === 3, "h should be 3"); | |
console.assert(p.a.b.c.d.e.f.g.h == 3, "should coerce proxy to a primitive"); | |
var s2 = JSON.stringify(p); | |
console.assert(s1 === s2, "should serialize *only* to f: 2"); | |
}).call(); | |
console.groupEnd("set value as object with toPrimitive fix"); | |
console.group("primitive keys"); | |
(function () { | |
var p = touch({}); | |
p.true | |
p[5] | |
p['a'] | |
p['2'] | |
// NOTE the print order of the keys does not match the insertion order, as the | |
// indexed/numeric keys are promoted. | |
console.assert( | |
JSON.stringify(p) === '{"2":{},"5":{},"true":{},"a":{}}', | |
"should apply all four keys as objects" | |
); | |
p.true = false; | |
p[5].a.b.c = 5; | |
p.a = []; | |
p.false = true; | |
var s2 = '{"2":{},"5":{"a":{"b":{"c":5}}},"true":false,"a":[],"false":true}'; | |
console.assert( | |
JSON.stringify(p) === s2, | |
"should apply all keys or replace key-values" | |
); | |
//////////////////////////////////////////////// | |
// 16 July 2023 | |
// Equality tests should move to their own group | |
//////////////////////////////////////////////// | |
console.assert(p.false.valueOf() === true, "should return true"); | |
console.assert(p.false.toJSON() == true, "should loosely equal true"); | |
console.assert(p.false.toJSON() === true, "should strictly equal true"); | |
console.assert(p.false.toString() === "true", `should return "true"`); | |
console.assert(p.true.valueOf() === false, "should return false"); | |
console.assert(p.true.toJSON() == false, "should loosely equal false"); | |
console.assert(p.true.toJSON() === false, "should strictly equal false"); | |
console.assert(p.true.toString() === "false", `should return "false"`); | |
// Aha! Symbols are primitives. | |
p[Symbol.for("jesse")] = 'JESSE'; | |
console.assert(JSON.stringify(p) === s2, "should not serialize symbol keys") | |
}).call(); | |
console.groupEnd("primitive keys"); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment