Skip to content

Instantly share code, notes, and snippets.

@DavidJCobb
Last active January 11, 2024 11:02
Show Gist options
  • Save DavidJCobb/d0c6f065a499e29d952f89a01a38a8b3 to your computer and use it in GitHub Desktop.
Save DavidJCobb/d0c6f065a499e29d952f89a01a38a8b3 to your computer and use it in GitHub Desktop.
JavaScript: using a Proxy to create const references to an object (i.e. `const Object&`)

The as_const function returns a Proxy wrapping the passed-in argument. This proxy attempts to prevent all modifications to the underlying object. The difference between this and using Object.freeze is that the latter makes the object itself immutable, while as_const simply prevents direct changes to the object via a specific reference to the object. Additionally, as_const prevents changes to any object that is referenced through the proxy (i.e. as_const(foo).bar.data = 5 is equivalent to as_const(foo.bar).data = 5 and will throw an exception).

In C++ terms, Object.freeze is akin to instantiating a const Object, whereas the result of as_const is a const Object& and the original object can still be modified elsewhere.

This idea is leaky enough that I would recommend against using it. If you need functionality like this, prefer a language that offers it reliably as a built-in feature.

Usage

function not_supposed_to_modify(data) {
   data.value = 3; // no! bad!!!
}

let original = {
   value: 5
};

not_supposed_to_modify(as_const(original));
// `as_const` will block the modification, and throw an error

Defects

JavaScript is highly dynamic, so it's impossible for this function to prevent modifications to a target object with perfect reliability. As an example, this will fail:

let wacky_object = {
   value: 5,
  
   modify_self: function() { this.value = 3; }
};

wacky_object.definitely_modify_self = wacky_object.modify_self.bind(wacky_object);

let const_ref = as_const(wacky_object);

try {
   const_ref.modify_self(); // throws, as desired
   //
   // This will throw, as desired. In JavaScript, methods aren't bound; they are 
   // freestanding functions, and the member access operator ('.') provides the 
   // lefthand-side expression as the value of `this`. In simpler terms: we are 
   // accessing the method through the const reference, so the method uses the 
   // const reference itself as `this`, and so throws an exception when it tries 
   // to make changes.
   //
} catch (e) {
   console.log(e.message); // logs an error, as desired
}
console.log(wacky_object.value); // 5: the value has not been changed

const_ref.definitely_modify_self(); // does not throw
//
// This will not throw. The `definitely_modify_self` method is a copy of the 
// `modify_self` method. This copy has been explicitly bound to the original 
// `wacky_object`, so it will use that as `this` no matter how we access it. 
// We cannot stop it from modifying the original object, even when we invoke 
// it through a const reference.

console.log(const_ref.value);    // 3: the value was changed
console.log(wacky_object.value); // 3: the value was changed on the original

One might be tempted to solve that particular edge-case by having the proxy rebind all methods whenever they're retrieved from the target object. However, that would be a wildly inappropriate approach. To put this in C++ terms: JavaScript doesn't have member functions (methods) per se; it only has function pointers as data members, where some are intended to be used as member functions, and some may not be. We cannot distinguish between the two. If we rebind all functions as they're retrieved, then we're assuming that all of these functions are [intended to be used as] methods; if one isn't — if it's passed around and used elsewhere — then we've potentially broken it by rebinding it.

There is no good way to make as_const 100% reliable; it is suitable only when a "good enough" solution is desired. Use as_const if you want to establish, by convention, that a value shouldn't be modified, but for important situations, strongly consider securing yourself against modifications by creating a disposable deep copy of the original object and passing that instead of [a const reference to] the original. A basic example:

some_callback(as_const(structuredClone(data)));

It's further worth noting that at that point, the callee can't modify the original object anyway, since it's operating on a copy, so as_const is of dubious value.

Rationale

I found myself in a situation where I needed to explain "constness" using multiple languages. I immediately had this idea and, to my surprise, I couldn't find an already existing implementation on Google that I could link to.

let as_const;
{
const _error_message = "cannot modify a const object";
// ensure this is actually undefined, in case outside code does anything wacky
let undefined;
let _proxy_handler = {
_species() {
return this;
},
get(target, key, receiver) {
let v = target[key];
if (key == Symbol.species) {
if (typeof target == "function") {
if (v === undefined) {
return this._species.bind(target);
}
}
}
if (v instanceof Object) {
return as_const(v);
}
return v;
},
// disable direct changes to the object:
set() { throw new Error(_error_message); },
setPrototypeOf() { throw new Error(_error_message); },
defineProperty() { throw new Error(_error_message); },
deleteProperty() { throw new Error(_error_message); },
isExtensible() { return false; },
// disable changes that might "bleed through" into the object, e.g.
// changing whether the original object can be extended:
preventExtensions() { throw new Error(_error_message); },
// non-const access to the prototype could be used to tamper with
// superclasses and make changes "from above." however, wrapping
// the prototype in a const reference means that instanceof tests
// against the prototype directly will fail. so sadly, we can't
// have getPrototypeOf return a const reference to the prototype.
};
as_const = function as_const(target) {
if (!(target instanceof Object)) {
return target; // primitives can't be modified anyway
}
return new Proxy(target, _proxy_handler);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment