Skip to content

Instantly share code, notes, and snippets.

@christophemarois
Created September 30, 2024 17:12
Show Gist options
  • Save christophemarois/9bfd0c8f84f7410891738db7b3c10a45 to your computer and use it in GitHub Desktop.
Save christophemarois/9bfd0c8f84f7410891738db7b3c10a45 to your computer and use it in GitHub Desktop.
utils
/* 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