0

I want to detect changes to arbitrary objects, such that I can react to them. I have the ability to intercept their creation process, so essentially the boilerplate I have is something like this:

function withChangeDetector(factory) {
    const obj = factory();
    return attachChangeListener(obj, () => console.log("Change occurred");
}

const myObject = withChangeDetector(() => new MyObject());
doSomethingThatMayChangeMyObject(myObject);
// if ^ changes myObject at any point in the future, then we should see the "Change occurred" log line

The constraints that I have are:

  1. I do not control the factory method, i.e. it can create any arbitrary object.
  2. I do not control the doSomethingThatMayChangeMyObject - i.e. anything may happen here.
  3. I DO control the wihChangeDetector/attachChangeListener functions.
  4. I can't use polling.

Now my first instinct is to use a proxy. Simplified, it would be something like this:

function attachChangeListener(obj, onChange) {
    // I'm ignoring nesting here, that's solvable and out of scope for this question. 
    return new Proxy(obj, {
        set: (target, key, val) => {
            const res = Reflect.set(target, key, val);
            onChange();
            return res;
        }
    });
}

Now this works great for external changes to an object, but unfortunately it doesn't quite work for internal changes in class instances. The problem is that if the caller defines their class like this:

class MyObject {
    prop;
    setProp = (val) => this.prop = val;
}

then this will be bound to the unproxied instance, and therefore the proxy will not be called, and it won't detect the change.

Hooking into get and doing some magic there is also not going to work, as this won't detect async internal changes to MyObject (e.g. imagine setProp using a timeout).

Is there any way that I can detect changes to MyObject - using a proxy or otherwise - given the constraints I outlined above?

0

1 Answer 1

1

Is there any way that I can detect changes to MyObject - using a proxy or otherwise - given the constraints I outlined above?

Not all kinds of changes, no, and not with perfect reliability. There are various things you can do to get close, but you can't get everything reliably if code is working directly on the object without going through your proxy.

You can detect changes to properties provided they're via assignment (not redefinition), but not additions/removals or redefinitions. (To catch those things, you'd need a proxy, but there's the issue of direct access as you describe in the question.)

Here's an example of catching changes by redefining properties as accessor properties:

class MyObject {
    #example;
    prop;
    setProp = (val) => (this.prop = val);
    get example() {
        return this.#example;
    }
    set example(newValue) {
        this.#example = newValue;
    }
}

function attachChangeListener(obj, onChange) {
    // In this example, we'll only worry about _own_ properties,
    // and only ones with string names, but you can adapt as
    // appropriate
    for (const name of Object.getOwnPropertyNames(obj)) {
        const descr = Object.getOwnPropertyDescriptor(obj, name);
        if ("value" in descr) {
            // It's a data property, turn it into an accessor property
            let value = obj[name];
            Object.defineProperty(obj, name, {
                get() {
                    return value;
                },
                set(newValue) {
                    value = newValue;
                    onChange(obj, name, newValue);
                },
                enumerable: descr.enumerable,
                // You can set `configurable to `false`, but then if the code
                // for the object does reconfigure the property, that code
                // will fail.
                configurable: true,
            });
        } else {
            // It's an accessor, insert ourselves in it
            const { get, set } = descr;
            Object.defineProperty(obj, name, {
                get() {
                    return get.call(obj);
                },
                set(newValue) {
                    set.call(obj);
                    onChange(obj, name, newValue);
                },
                enumerable: descr.enumerable,
                // See note about `configurable` above
                configurable: true,
            });
        }
    }

    // For `MyObject`, we'd probably want to at least check the immediate prototype (if
    // not the entire chain) to catch accessors defined on `MyObject.prototype` rather
    // that directly on the object.
    const proto = Object.getPrototypeOf(obj);
    if (proto) {
        for (const name of Object.getOwnPropertyNames(proto)) {
            const descr = Object.getOwnPropertyDescriptor(proto, name);
            if (!("value" in descr)) {
                // It's an accessor, override it with our own on the object
                const { get, set } = descr;
                Object.defineProperty(obj, name, {
                    get() {
                        return get.call(obj);
                    },
                    set(newValue) {
                        set.call(obj);
                        onChange(obj, name, newValue);
                    },
                    enumerable: descr.enumerable,
                    // See note about `configurable` above
                    configurable: true,
                });
            }
        }
    }

    // You'd probably *also* add your proxy here
    return obj;
}

const inst = attachChangeListener(new MyObject(), function (obj, name, value) {
    console.log(`**Change detected** ${name} changed to ${value}`);
});

// You'll be notified of this change
inst.setProp("x");
// And of this one
inst.example = 42;
// But not this one
Object.defineProperty(inst, "example", {
    value: "silent change",
    writable: true,
    enumerable: true,
    configurable: true,
});
console.log(`example = ${inst.example}`);

// And not this addition
inst.newProperty = "added";
console.log(`newProperty = ${inst.newProperty}`);

Note the examples at the end of changes that aren't detected. Similarly, in that particular example, accessors on the prototype's prototype wouldn't be handled (for instance, class X extends Y where the accessors are defined by Y). There are probably several holes like that.

But for an object that's created with a stable set of properties whose values are only changed via assignment, not redefinition, replacing the properties with accessors can be powerful. Just beware, there are lots of holes. :-)

Sign up to request clarification or add additional context in comments.

2 Comments

Thanks! I think it's a testament to JS' evolution that I've completely forgotten that these "cowboy patterns" exist where we just straight up modify the prototype chain. I think that we can slightly improve on the solution by swapping out the prototype with a modified one, instead of changing the original prototype. That reduces the chance of messing up other bits of the application. That said, TBH I'm not entirely sure if I'd want to employ this in any real-world application, but at least it gives me a good hook to explore further.
@Tiddo - While you could insert your own prototype behind obj, that wouldn't help you with own properties of obj -- but it could be a piece of the patchwork. :-)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.