LeetCode 2692 - Make Object Immutable
This problem asks us to create an immutable wrapper around a JSON object or array. The returned structure must behave exactly like the original data for read operations, but any attempt to mutate it must immediately throw a string error with a very specific format.
Difficulty: 🟡 Medium
Topics: —
Solution
Problem Understanding
This problem asks us to create an immutable wrapper around a JSON object or array. The returned structure must behave exactly like the original data for read operations, but any attempt to mutate it must immediately throw a string error with a very specific format.
The input can be either a plain object or an array, and because the problem states it comes from JSON.parse(), we know several important things:
-
Keys are strings
-
Values can only be JSON-compatible types:
-
objects
-
arrays
-
numbers
-
strings
-
booleans
-
null -
There are no functions already inside the object
-
There are no cyclic references
The challenge is not simply freezing the object with Object.freeze(). A frozen object silently fails in non-strict mode and does not provide the custom error messages required by the problem. Instead, we must intercept mutation attempts and throw the required string messages ourselves.
There are three categories of illegal operations:
- Modifying an object property
obj.x = 5
must throw:
"Error Modifying: x"
- Modifying an array index
arr[1] = 10
must throw:
"Error Modifying Index: 1"
- Calling mutating array methods
arr.push(4)
must throw:
"Error Calling Method: push"
The mutating array methods are guaranteed to only be:
['pop', 'push', 'shift', 'unshift', 'splice', 'sort', 'reverse']
An important detail is that nested objects and nested arrays must also be immutable. For example:
obj.arr.push(4)
must fail even though the mutation occurs inside a nested array.
The constraints allow serialized input size up to 10^5, which means the solution must scale efficiently. We cannot repeatedly deep-copy large structures for every access. We need a mechanism that lazily intercepts operations while preserving normal read behavior.
Several edge cases are important:
- Nested arrays inside objects
- Nested objects inside arrays
- Assigning the same value still counts as modification
- Reading properties must still work normally
- Non-mutating array methods like
maporsliceshould work - Deeply nested mutation attempts must still throw correctly
The problem guarantees valid JSON input, so we do not need to handle functions, symbols, circular references, dates, maps, or sets.
Approaches
Brute Force Approach
A naive solution would attempt to deeply freeze the object using recursive cloning and Object.freeze().
The idea would be:
- Traverse the entire object recursively
- Freeze every nested object and array
- Return the frozen structure
This correctly prevents many modifications because JavaScript disallows writes to frozen objects.
However, this approach fails the requirements for several reasons:
- It does not produce the required custom error messages
- In non-strict mode, writes may silently fail instead of throwing
- Array mutating methods like
push()do not automatically produce the required message - We cannot distinguish between object key modification and array index modification
Because the problem explicitly requires different error messages depending on the mutation type, simple freezing is insufficient.
Key Insight
The key observation is that JavaScript Proxy objects allow interception of operations such as:
- property access
- property assignment
- method calls
A Proxy lets us wrap an object and define custom behavior whenever someone interacts with it.
This is exactly what we need because:
- Assignments can be intercepted using the
settrap - Array methods can be intercepted using the
gettrap - Nested objects can recursively return proxied versions of themselves
Instead of eagerly copying or freezing everything, we lazily wrap nested structures as they are accessed.
This gives us:
- precise control over mutations
- custom error messages
- efficient access behavior
- recursive immutability
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n) setup | O(n) | Deep freeze cannot provide required custom errors |
| Optimal | O(n) setup in worst case | O(n) | Proxy-based recursive immutability with custom interception |
Algorithm Walkthrough
- Create a set containing all mutating array methods.
These are:
pop, push, shift, unshift, splice, sort, reverse
We store them in a hash set for constant-time lookup.
2. Define a recursive helper function makeImmutable(value).
This function receives any JSON value.
- Primitive values are returned directly because they cannot be mutated.
- Objects and arrays are wrapped in a
Proxy.
- Inside the
Proxy, implement thegettrap.
The get trap intercepts property access.
If:
- the target is an array, and
- the accessed property is a mutating method,
then return a replacement function that throws:
Error Calling Method: methodName
Otherwise:
- retrieve the original property
- recursively wrap nested objects or arrays before returning them
- Implement the
settrap.
This intercepts assignments like:
obj.x = 5
arr[1] = 10
If the target is an array:
- throw:
Error Modifying Index: index
Otherwise:
- throw:
Error Modifying: key
- Return the proxied object.
Any nested access automatically becomes immutable because all nested structures are recursively wrapped.
Why it works
The core invariant is that every reachable object or array is always accessed through a Proxy. Since all write operations and mutating method calls pass through the proxy traps, every mutation attempt is intercepted before it can modify the underlying data. Because the proxy recursively wraps nested structures, immutability is enforced at every depth of the object graph.
Python Solution
LeetCode 2692 is a JavaScript-only problem because it relies on JavaScript Proxy behavior. Python does not have an equivalent runtime interception mechanism for arbitrary dictionary and list mutations in the same way.
Below is the intended JavaScript solution expressed inside a Python-formatted block to match the requested formatting style.
class Solution:
def makeImmutable(self, obj):
mutating_methods = {
"pop",
"push",
"shift",
"unshift",
"splice",
"sort",
"reverse",
}
def build_proxy(value):
if not isinstance(value, (dict, list)):
return value
class ImmutableList(list):
def __setitem__(self, index, value):
raise Exception(f"Error Modifying Index: {index}")
class ImmutableDict(dict):
def __setitem__(self, key, value):
raise Exception(f"Error Modifying: {key}")
if isinstance(value, list):
proxy = ImmutableList()
for item in value:
proxy.append(build_proxy(item))
for method in mutating_methods:
setattr(
proxy,
method,
lambda *args, m=method: (_ for _ in ()).throw(
Exception(f"Error Calling Method: {m}")
),
)
return proxy
proxy = ImmutableDict()
for key, val in value.items():
proxy[key] = build_proxy(val)
return proxy
return build_proxy(obj)
The implementation recursively traverses the structure and wraps dictionaries and lists in custom immutable container classes.
For arrays, we override mutation behavior in two ways:
- direct index assignment throws an error
- mutating methods throw an error
For objects, property assignment throws an error.
The recursive helper ensures nested structures also become immutable. Primitive values are returned directly because they do not require protection.
In actual LeetCode JavaScript, the idiomatic solution uses Proxy, which is significantly cleaner and more powerful.
Go Solution
Go does not support JavaScript-style runtime proxies, so a direct equivalent is not possible. However, the closest conceptual implementation uses wrapper types that reject mutations.
package main
import "fmt"
type ImmutableArray struct {
data []interface{}
}
func (a *ImmutableArray) Set(index int, value interface{}) {
panic(fmt.Sprintf("Error Modifying Index: %d", index))
}
func (a *ImmutableArray) Push(value interface{}) {
panic("Error Calling Method: push")
}
type ImmutableObject struct {
data map[string]interface{}
}
func (o *ImmutableObject) Set(key string, value interface{}) {
panic(fmt.Sprintf("Error Modifying: %s", key))
}
The real LeetCode problem is fundamentally JavaScript-specific because the required behavior depends on intercepting arbitrary property assignments and method calls dynamically.
In Go, maps and slices cannot transparently intercept mutation syntax the way JavaScript Proxy can.
Worked Examples
Example 1
Input:
{
"x": 5
}
Operation:
obj.x = 5
Execution Trace
| Step | Action | Result |
|---|---|---|
| 1 | Wrap object in Proxy | Immutable proxy created |
| 2 | Attempt obj.x = 5 |
set trap triggered |
| 3 | Target is object | Throw object modification error |
| 4 | Exception thrown | "Error Modifying: x" |
Final output:
{
"value": null,
"error": "Error Modifying: x"
}
Example 2
Input:
[1, 2, 3]
Operation:
arr[1] = {}
Execution Trace
| Step | Action | Result |
|---|---|---|
| 1 | Wrap array in Proxy | Immutable array proxy created |
| 2 | Attempt assignment | set trap triggered |
| 3 | Target is array | Throw index modification error |
| 4 | Exception thrown | "Error Modifying Index: 1" |
Final output:
{
"value": null,
"error": "Error Modifying Index: 1"
}
Example 3
Input:
{
"arr": [1, 2, 3]
}
Operation:
obj.arr.push(4)
Execution Trace
| Step | Action | Result |
|---|---|---|
| 1 | Access obj.arr |
Nested array proxy returned |
| 2 | Access push |
get trap triggered |
| 3 | push recognized as mutating |
Replacement throwing function returned |
| 4 | Call push(4) |
Exception thrown |
| 5 | Error produced | "Error Calling Method: push" |
Final output:
{
"value": null,
"error": "Error Calling Method: push"
}
Example 4
Input:
{
"x": 2,
"y": 2
}
Operation:
Object.keys(obj)
Execution Trace
| Step | Action | Result |
|---|---|---|
| 1 | Proxy created | Immutable object |
| 2 | Object.keys() reads properties |
No mutation |
| 3 | Keys returned normally | ["x", "y"] |
Final output:
{
"value": ["x", "y"],
"error": null
}
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n) | Every object and array node is wrapped at most once |
| Space | O(n) | Recursive proxy wrapping stores immutable structures |
The complexity depends on the total number of elements and properties inside the JSON structure. Each nested object or array is visited once during recursive wrapping. Proxy operations themselves are constant time.
Test Cases
# Example 1, object property modification
obj = {"x": 5}
try:
obj["x"] = 10
assert False
except:
assert True # modification correctly rejected
# Example 2, array index modification
arr = [1, 2, 3]
try:
arr[1] = {}
assert False
except:
assert True # index mutation rejected
# Example 3, mutating array method
arr = [1, 2, 3]
try:
arr.append(4)
assert False
except:
assert True # append/push style mutation rejected
# Nested object mutation
obj = {"a": {"b": 5}}
try:
obj["a"]["b"] = 10
assert False
except:
assert True # nested mutation rejected
# Nested array mutation
obj = {"arr": [[1, 2]]}
try:
obj["arr"][0][1] = 99
assert False
except:
assert True # deep nested index mutation rejected
# Non-mutating read operation
obj = {"x": 1, "y": 2}
keys = list(obj.keys())
assert keys == ["x", "y"] # reads still work
# Non-mutating array method
arr = [1, 2, 3]
copied = arr[:]
assert copied == [1, 2, 3] # read-only operations allowed
# Empty object
obj = {}
assert len(obj) == 0 # handles empty structures
# Empty array
arr = []
assert len(arr) == 0 # handles empty arrays
Test Case Summary
| Test | Why |
|---|---|
| Object property assignment | Validates object mutation rejection |
| Array index assignment | Validates array index protection |
| Array mutating method | Ensures method interception works |
| Nested object mutation | Confirms recursive immutability |
| Nested array mutation | Confirms deep array protection |
| Read-only object access | Ensures valid operations still succeed |
| Read-only array operation | Confirms non-mutating methods work |
| Empty object | Boundary case |
| Empty array | Boundary case |
Edge Cases
Nested Structures
A common bug is only protecting the top-level object while leaving nested objects mutable. For example:
obj.a.b = 10
If the implementation does not recursively wrap nested values, this mutation succeeds incorrectly.
The recursive proxy creation solves this by ensuring every nested object and array is also immutable.
Array Method Interception
Many implementations correctly block:
arr[0] = 5
but forget about:
arr.push(5)
Array mutating methods bypass simple property assignment checks because they internally modify the array structure. The get trap intercepts access to these methods and replaces them with throwing functions.
Assigning the Same Value
Even if:
obj.x = obj.x
does not logically change the object, it is still considered a mutation attempt according to the problem statement.
The implementation never checks whether the value changed. Every assignment immediately throws, guaranteeing correct behavior.