Created
September 30, 2024 17:12
-
-
Save christophemarois/9bfd0c8f84f7410891738db7b3c10a45 to your computer and use it in GitHub Desktop.
utils
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable no-console */ | |
/** | |
* Represents a deeply readonly version of a type, including nested objects, | |
* arrays, sets, and maps. | |
* @template T - The type to make deeply readonly. | |
*/ | |
export type DeepReadonly<T> = T extends (infer R)[] | |
? ReadonlyArray<DeepReadonly<R>> | |
: T extends Set<infer R> | |
? ReadonlySet<DeepReadonly<R>> | |
: T extends Map<infer K, infer V> | |
? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> | |
: T extends object | |
? { readonly [P in keyof T]: DeepReadonly<T[P]> } | |
: T | |
/** | |
* Creates a deeply immutable (readonly) version of the provided object. This | |
* function handles nested objects, arrays, sets, and maps. Any attempt to | |
* modify the returned object or its nested properties will throw an error with | |
* a stack trace. | |
* | |
* @template T - The type of the object to make immutable. | |
* @param {T} obj - The object to make immutable. | |
* @returns {DeepReadonly<T>} A deeply immutable version of the input object. | |
* | |
* @throws {Error} Throws an error if any modification is attempted on the | |
* immutable object. | |
* | |
* @example | |
* const original = { | |
* name: "John", | |
* hobbies: ["reading"], | |
* tags: new Set(["friendly"]), | |
* scores: new Map([["math", 95]]) | |
* }; | |
* const immutable = createImmutableObject(original); | |
* | |
* // These will throw errors: | |
* // immutable.name = "Jane"; | |
* // immutable.hobbies.push("swimming"); | |
* // immutable.tags.add("programmer"); | |
* // immutable.scores.set("science", 92); | |
* | |
* // These are fine (reading is allowed): | |
* console.log(immutable.name); | |
* console.log(immutable.hobbies[0]); | |
* console.log(immutable.tags.has("friendly")); | |
* console.log(immutable.scores.get("math")); | |
*/ | |
export function createImmutableObject<T>(obj: T): DeepReadonly<T> { | |
function createProxy(target: any, path: string[] = []): any { | |
if (Array.isArray(target)) { | |
return new Proxy(target, { | |
get(target, prop: string | symbol) { | |
if (typeof prop === 'string') { | |
const numProp = Number(prop) | |
if (!isNaN(numProp)) { | |
const value = target[numProp] | |
return typeof value === 'object' && value !== null | |
? createProxy(value, [...path, prop]) | |
: value | |
} | |
} | |
if (prop in Array.prototype) { | |
const method = Array.prototype[prop as keyof typeof Array.prototype] | |
if ( | |
typeof method === 'function' && | |
[ | |
'pop', | |
'push', | |
'reverse', | |
'shift', | |
'sort', | |
'splice', | |
'unshift', | |
].includes(prop.toString()) | |
) { | |
return () => { | |
const fullPath = [...path, prop.toString()].join('.') | |
const error = new Error( | |
`Attempted to modify immutable array using method: ${fullPath}` | |
) | |
throw error | |
} | |
} | |
} | |
return Reflect.get(target, prop) | |
}, | |
set(_, prop, value) { | |
const fullPath = [...path, prop.toString()].join('.') | |
const error = new Error( | |
`Attempted to modify immutable array at index: ${fullPath}` | |
) | |
console.error(error.stack) | |
throw error | |
}, | |
}) | |
} else if (target instanceof Set) { | |
return new Proxy(target, { | |
get(target, prop: string | symbol) { | |
if (prop in Set.prototype) { | |
const method = Set.prototype[prop as keyof typeof Set.prototype] | |
if ( | |
typeof method === 'function' && | |
['add', 'clear', 'delete'].includes(prop.toString()) | |
) { | |
return () => { | |
const fullPath = [...path, prop.toString()].join('.') | |
const error = new Error( | |
`Attempted to modify immutable set using method: ${fullPath}` | |
) | |
throw error | |
} | |
} | |
} | |
return Reflect.get(target, prop) | |
}, | |
}) | |
} else if (target instanceof Map) { | |
return new Proxy(target, { | |
get(target, prop: string | symbol) { | |
if (prop in Map.prototype) { | |
const method = Map.prototype[prop as keyof typeof Map.prototype] | |
if ( | |
typeof method === 'function' && | |
['clear', 'delete', 'set'].includes(prop.toString()) | |
) { | |
return () => { | |
const fullPath = [...path, prop.toString()].join('.') | |
const error = new Error( | |
`Attempted to modify immutable map using method: ${fullPath}` | |
) | |
throw error | |
} | |
} | |
if (prop.toString() === 'get') { | |
return (key: any) => { | |
const value = target.get(key) | |
return typeof value === 'object' && value !== null | |
? createProxy(value, [...path, 'get', key.toString()]) | |
: value | |
} | |
} | |
} | |
return Reflect.get(target, prop) | |
}, | |
}) | |
} else if (typeof target === 'object' && target !== null) { | |
return new Proxy(target, { | |
deleteProperty(_, prop) { | |
const fullPath = [...path, prop.toString()].join('.') | |
const error = new Error( | |
`Attempted to delete property from immutable object at path: ${fullPath}` | |
) | |
console.error(error.stack) | |
throw error | |
}, | |
get(target, prop: string | symbol) { | |
const value = Reflect.get(target, prop) | |
if (typeof value === 'object' && value !== null) { | |
return createProxy(value, [...path, prop.toString()]) | |
} | |
return value | |
}, | |
set(_, prop, value) { | |
const fullPath = [...path, prop.toString()].join('.') | |
const error = new Error( | |
`Attempted to modify immutable object at path: ${fullPath}` | |
) | |
console.error(error.stack) | |
throw error | |
}, | |
}) | |
} | |
return target | |
} | |
return createProxy(obj) as DeepReadonly<T> | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment