@qntm-code/utils
Version:
A collection of useful utility functions with associated TypeScript types. All functions have been unit tested.
329 lines (328 loc) • 11.2 kB
JavaScript
import { typeOf, ValueType } from '../type-predicates';
/**
* Recursively (deep) freezes objects/arrays and freezes nested values (best-effort).
*
* Important: JavaScript cannot universally enforce deep immutability for every built-in.
* This function makes the *returned value* behave as readonly where possible.
*
* Notes on exotic built-ins:
* - `Map`/`Set` internal entries are not made immutable by `Object.freeze`, so this function returns a readonly `Proxy`
* that throws on mutating operations.
* - `Date` and ArrayBuffer views (TypedArrays/DataView/Buffer) have mutating APIs that bypass `Object.freeze`, so this
* function returns a readonly `Proxy` that throws on common mutators.
* - If other code still holds a reference to the original mutable object, it can mutate it directly; `freeze()` cannot
* prevent that.
*/
export function freeze(value) {
const ctx = {
seen: new WeakSet(),
cache: new WeakMap(),
};
return freezeInternal(value, ctx);
}
function isObjectLike(value) {
return (typeof value === 'object' && value !== null) || typeof value === 'function';
}
function freezeInternal(value, ctx) {
if (!isObjectLike(value)) {
return value;
}
const cached = ctx.cache.get(value);
if (cached) {
return cached;
}
if (ctx.seen.has(value)) {
return value;
}
ctx.seen.add(value);
ctx.cache.set(value, value);
const valueType = typeOf(value);
switch (valueType) {
case ValueType.array: {
return freezeArrayDeep(value, ctx);
}
case ValueType.object: {
return freezeObjectDeep(value, ctx);
}
case ValueType.map: {
return freezeMapDeep(value, ctx);
}
case ValueType.set: {
return freezeSetDeep(value, ctx);
}
case ValueType.date: {
return freezeDate(value, ctx);
}
case ValueType.regexp: {
return Object.freeze(value);
}
case ValueType.error: {
return freezeObjectDeep(value, ctx);
}
case ValueType.weakmap: {
return freezeWeakMap(value, ctx);
}
case ValueType.weakset: {
return freezeWeakSet(value, ctx);
}
case ValueType.buffer:
case ValueType.int8array:
case ValueType.uint8array:
case ValueType.uint8clampedarray:
case ValueType.int16array:
case ValueType.uint16array:
case ValueType.int32array:
case ValueType.uint32array:
case ValueType.float32array:
case ValueType.float64array:
case ValueType.bigint64array:
case ValueType.biguint64array: {
return freezeArrayBufferView(value, ctx);
}
default: {
// Best-effort: freeze any other object-like value (e.g. Promise, iterators, functions with props)
return freezeObjectDeep(value, ctx);
}
}
}
function freezeObjectDeep(obj, ctx) {
const propKeys = Reflect.ownKeys(obj);
for (const key of propKeys) {
const desc = Object.getOwnPropertyDescriptor(obj, key);
if (!desc) {
continue;
}
if ('value' in desc) {
const frozenChild = freezeInternal(desc.value, ctx);
if (frozenChild !== desc.value) {
if (desc.writable) {
Reflect.set(obj, key, frozenChild);
}
else if (desc.configurable) {
Object.defineProperty(obj, key, {
...desc,
value: frozenChild,
});
}
}
continue;
}
// Accessors: nothing to traverse without invoking the getter.
// Still freeze the descriptor via Object.freeze(obj) below.
}
return Object.freeze(obj);
}
function freezeArrayDeep(array, ctx) {
for (let i = 0; i < array.length; i++) {
const frozenChild = freezeInternal(array[i], ctx);
if (frozenChild !== array[i]) {
Reflect.set(array, String(i), frozenChild);
}
}
// Handle custom properties on arrays too (including symbols)
const extraKeys = Reflect.ownKeys(array).filter(k => {
return typeof k !== 'string' || !/^\d+$/.test(k);
});
for (const key of extraKeys) {
const desc = Object.getOwnPropertyDescriptor(array, key);
if (!desc || !('value' in desc)) {
continue;
}
const frozenChild = freezeInternal(desc.value, ctx);
if (frozenChild !== desc.value) {
if (desc.writable) {
Reflect.set(array, key, frozenChild);
}
else if (desc.configurable) {
Object.defineProperty(array, key, {
...desc,
value: frozenChild,
});
}
}
}
return Object.freeze(array);
}
function freezeMapDeep(map, ctx) {
const proxy = createReadonlyProxy(map, ctx, {
throwOnMethodNames: ['set', 'delete', 'clear'],
});
const updates = [];
map.forEach((value, key) => {
// Do not replace keys (would break lookups); still freeze deeply.
freezeInternal(key, ctx);
const frozenValue = freezeInternal(value, ctx);
if (frozenValue !== value) {
updates.push({ key, value: frozenValue });
}
});
for (const { key, value } of updates) {
map.set(key, value);
}
defineThrowingMethods(map, ['set', 'delete', 'clear']);
Object.freeze(map);
return proxy;
}
function freezeSetDeep(set, ctx) {
const proxy = createReadonlyProxy(set, ctx, {
throwOnMethodNames: ['add', 'delete', 'clear'],
});
set.forEach(value => {
// Do not replace elements (would break membership semantics); still freeze deeply.
freezeInternal(value, ctx);
});
defineThrowingMethods(set, ['add', 'delete', 'clear']);
Object.freeze(set);
return proxy;
}
function freezeWeakMap(map, ctx) {
const proxy = createReadonlyProxy(map, ctx, {
throwOnMethodNames: ['set', 'delete'],
});
Object.freeze(map);
return proxy;
}
function freezeWeakSet(set, ctx) {
const proxy = createReadonlyProxy(set, ctx, {
throwOnMethodNames: ['add', 'delete'],
});
Object.freeze(set);
return proxy;
}
function freezeDate(date, ctx) {
const proxy = createReadonlyProxy(date, ctx, {
throwOnMethodNamePredicate: name => name.startsWith('set'),
});
defineThrowingMethodsByPrototype(date, Date.prototype, name => name.startsWith('set'));
Object.freeze(date);
return proxy;
}
function freezeArrayBufferView(view, ctx) {
const isDataView = Object.prototype.toString.call(view) === '[object DataView]';
defineThrowingMethods(view, ['set', 'copyWithin', 'fill', 'reverse', 'sort', 'write', 'copy']);
defineThrowingMethodsByPrototype(view,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
view.constructor?.prototype, name => {
if (name.startsWith('write')) {
return true;
}
if (isDataView && name.startsWith('set')) {
return true;
}
return false;
});
const proxy = createReadonlyProxy(view, ctx, {
throwOnMethodNamePredicate: name => {
// TypedArray mutators
if (name === 'set' || name === 'copyWithin' || name === 'fill' || name === 'reverse' || name === 'sort') {
return true;
}
// Buffer mutators (best-effort)
if (name === 'write' || name === 'copy') {
return true;
}
if (name.startsWith('write')) {
return true;
}
// DataView mutators
if (isDataView && name.startsWith('set')) {
return true;
}
return false;
},
});
// Some runtimes (notably browsers) throw when calling Object.freeze on typed arrays.
// The proxy already blocks indexed writes and common mutator methods.
return proxy;
}
function defineThrowingMethods(target, methodNames) {
for (const name of methodNames) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (typeof target[name] !== 'function') {
continue;
}
Object.defineProperty(target, name, {
value: () => {
throw new TypeError('Cannot modify a frozen value');
},
configurable: false,
writable: false,
enumerable: false,
});
}
catch {
// Ignore if defineProperty fails for this target/method.
}
}
}
function defineThrowingMethodsByPrototype(target, prototype, predicate) {
if (!prototype) {
return;
}
for (const key of Object.getOwnPropertyNames(prototype)) {
if (!predicate(key)) {
continue;
}
defineThrowingMethods(target, [key]);
}
}
function createReadonlyProxy(target, ctx, options) {
const cached = ctx.cache.get(target);
if (cached && cached !== target) {
return cached;
}
const throwOnMethodNames = new Set(options.throwOnMethodNames ?? []);
const shouldThrow = (prop) => {
if (typeof prop !== 'string') {
return false;
}
if (throwOnMethodNames.has(prop)) {
return true;
}
return options.throwOnMethodNamePredicate ? options.throwOnMethodNamePredicate(prop) : false;
};
const proxy = new Proxy(target, {
defineProperty() {
throw new TypeError('Cannot modify a frozen value');
},
deleteProperty() {
throw new TypeError('Cannot modify a frozen value');
},
set() {
throw new TypeError('Cannot modify a frozen value');
},
setPrototypeOf() {
throw new TypeError('Cannot modify a frozen value');
},
preventExtensions() {
// Allow Object.isFrozen/Object.preventExtensions checks without throwing.
try {
Object.preventExtensions(target);
}
catch {
// Some exotic objects (e.g. certain ArrayBuffer views) can throw here.
}
return true;
},
isExtensible() {
return Object.isExtensible(target);
},
get(t, prop, receiver) {
if (shouldThrow(prop)) {
return () => {
throw new TypeError('Cannot modify a frozen value');
};
}
const value = Reflect.get(t, prop, receiver);
if (typeof value === 'function') {
// Bind to the real target so built-in methods see the right internal slots.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return value.bind(t);
}
return value;
},
});
ctx.cache.set(target, proxy);
return proxy;
}