An immutable wrapper for a collection makes that collection immutable by wrapping it in a new object. In this chapter, we examine how that works and why it is useful.
If there is an object whose interface we’d like to reduce, we can take the following approach:
This is what wrapping looks like:
class Wrapper {
#wrapped;
constructor(wrapped) {
this.#wrapped = wrapped;
}
allowedMethod1(...args) {
return this.#wrapped.allowedMethod1(...args);
}
allowedMethod2(...args) {
return this.#wrapped.allowedMethod2(...args);
}
}
Related software design patterns:
To make a collection immutable, we can use wrapping and remove all destructive operations from its interface.
One important use case for this technique is an object that has an internal mutable data structure that it wants to export safely without copying it. The export being “live” may also be a goal. The object can achieve its goals by wrapping the internal data structure and making it immutable.
The next two sections showcase immutable wrappers for Maps and Arrays. They both have the following limitations:
Class ImmutableMapWrapper
produces wrappers for Maps:
class ImmutableMapWrapper {
static _setUpPrototype() {
// Only forward non-destructive methods to the wrapped Map:
for (const methodName of ['get', 'has', 'keys', 'size']) {
ImmutableMapWrapper.prototype[methodName] = function (...args) {
return this.#wrappedMap[methodName](...args);
}
}
}
#wrappedMap;
constructor(wrappedMap) {
this.#wrappedMap = wrappedMap;
}
}
ImmutableMapWrapper._setUpPrototype();
The setup of the prototype has to be performed by a static method, because we only have access to the private field .#wrappedMap
from inside the class.
This is ImmutableMapWrapper
in action:
const map = new Map([[false, 'no'], [true, 'yes']]);
const wrapped = new ImmutableMapWrapper(map);
// Non-destructive operations work as usual:
assert.equal(
wrapped.get(true), 'yes');
assert.equal(
wrapped.has(false), true);
assert.deepEqual(
[...wrapped.keys()], [false, true]);
// Destructive operations are not available:
assert.throws(
() => wrapped.set(false, 'never!'),
/^TypeError: wrapped.set is not a function$/);
assert.throws(
() => wrapped.clear(),
/^TypeError: wrapped.clear is not a function$/);
For an Array arr
, normal wrapping is not enough because we need to intercept not just method calls, but also property accesses such as arr[1] = true
. JavaScript proxies enable us to do this:
const RE_INDEX_PROP_KEY = /^[0-9]+$/;
const ALLOWED_PROPERTIES = new Set([
'length', 'constructor', 'slice', 'concat']);
function wrapArrayImmutably(arr) {
const handler = {
get(target, propKey, receiver) {
// We assume that propKey is a string (not a symbol)
if (RE_INDEX_PROP_KEY.test(propKey) // simplified check!
|| ALLOWED_PROPERTIES.has(propKey)) {
return Reflect.get(target, propKey, receiver);
}
throw new TypeError(`Property "${propKey}" can’t be accessed`);
},
set(target, propKey, value, receiver) {
throw new TypeError('Setting is not allowed');
},
deleteProperty(target, propKey) {
throw new TypeError('Deleting is not allowed');
},
};
return new Proxy(arr, handler);
}
Let’s wrap an Array:
const arr = ['a', 'b', 'c'];
const wrapped = wrapArrayImmutably(arr);
// Non-destructive operations are allowed:
assert.deepEqual(
wrapped.slice(1), ['b', 'c']);
assert.equal(
wrapped[1], 'b');
// Destructive operations are not allowed:
assert.throws(
() => wrapped[1] = 'x',
/^TypeError: Setting is not allowed$/);
assert.throws(
() => wrapped.shift(),
/^TypeError: Property "shift" can’t be accessed$/);