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.

LeetCode Problem 2692

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:

  1. Modifying an object property
obj.x = 5

must throw:

"Error Modifying: x"
  1. Modifying an array index
arr[1] = 10

must throw:

"Error Modifying Index: 1"
  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 map or slice should 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:

  1. Traverse the entire object recursively
  2. Freeze every nested object and array
  3. 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 set trap
  • Array methods can be intercepted using the get trap
  • 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

  1. 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.
  1. Inside the Proxy, implement the get trap.

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
  1. Implement the set trap.

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
  1. 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.