Skip to content

Instantly share code, notes, and snippets.

@dfkaye
Last active July 17, 2023 00:06
Show Gist options
  • Save dfkaye/e48acca7e7f2b16d8f47bd5d4d78caee to your computer and use it in GitHub Desktop.
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
// 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