Skip to content

2691. Immutability Helper

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
type JSONValue =
  | null
  | boolean
  | number
  | string
  | JSONValue[]
  | { [key: string]: JSONValue };
type InputObj = Record<string, JSONValue> | Array<JSONValue>;

type RecursiveHandler = {
  set: (target: any, prop: string, value: any) => boolean;
  get: (target: any, prop: string) => unknown;
};

const isObject = (o: any) => o !== null && typeof o === 'object';

// Access history of properties
class AccessHistory {
  value: JSONValue | null = null;
  props: Map<string, AccessHistory> = new Map();
}

class ImmutableHelper {
  private obj: InputObj;

  constructor(obj: InputObj) {
    this.obj = obj;
  }

  produce(mutator: (obj: InputObj) => void): InputObj {
    // Creates a proxied object to track property access history.
    function createProxiedObj(
      obj: InputObj,
      accessHistory: AccessHistory
    ): InputObj {
      const handler: RecursiveHandler = {
        // 'set' trap intercepts property assignment.
        set(_, prop, value) {
          if (!accessHistory.props.has(prop)) {
            accessHistory.props.set(prop, new AccessHistory());
          }
          accessHistory.props.get(prop)!.value = value;
          return true;
        },
        // 'get' trap intercepts property access.
        get(_, prop) {
          if (accessHistory.value !== null) {
            return accessHistory.value;
          }
          if (!accessHistory.props.has(prop)) {
            accessHistory.props.set(prop, new AccessHistory());
          }
          if (accessHistory.props.get(prop)!.value !== null) {
            return accessHistory.props.get(prop)!.value;
          }
          if (isObject(obj[prop])) {
            // Recursively create a proxed object for object property.
            return createProxiedObj(
              obj[prop] as InputObj,
              accessHistory.props.get(prop)! as AccessHistory
            );
          }
          return obj[prop];
        },
      };
      return new Proxy(obj, handler);
    }

    // Returns true if there are mutated properties in the access history;
    // otherwise, returns false and deletes the unnecessary properties.
    function deleteUnmutatedProps(accessHistory: AccessHistory): boolean {
      if (accessHistory.value !== null) {
        return true;
      }
      let hasMutation = false;
      for (const [prop, childAccessHistory] of [...accessHistory.props]) {
        if (deleteUnmutatedProps(childAccessHistory)) {
          hasMutation = true;
        } else {
          accessHistory.props.delete(prop);
        }
      }
      return hasMutation;
    }

    // Function to transform the original object based on the access history
    function transform(obj: InputObj, accessHistory: AccessHistory): InputObj {
      if (accessHistory.value !== null) {
        return accessHistory.value as InputObj;
      }
      if (accessHistory.props.size === 0) {
        return obj;
      }
      if (!isObject(obj)) {
        return obj;
      }
      let clone = Array.isArray(obj) ? [...obj] : { ...obj };
      for (const [prop, childAccessHistory] of [...accessHistory.props]) {
        clone[prop] = transform(obj[prop] as InputObj, childAccessHistory);
      }
      return clone;
    }

    const accessHistory = new AccessHistory();
    const proxiedObj = createProxiedObj(this.obj, accessHistory);
    // Apply the mutator function on the proxied object. This will also record
    // the property access history in `accessHistory`.
    mutator(proxiedObj);
    // Simplify the access history.
    deleteUnmutatedProps(accessHistory);
    // Transform the original object based on the simplified access history.
    return transform(this.obj, accessHistory);
  }
}