Creating a Deep Observer in TypeScript

In this blog post, we’ll explore the concept of deep observation in TypeScript and learn how to create a deep observer using proxies. Deep observation allows you to track changes made to an object, including its nested properties, providing a powerful way to monitor and react to modifications in your data structures.

Understanding Deep Observation

Deep observation involves monitoring changes made to an object and its nested properties. When a property is modified, whether it’s a top-level property or a deeply nested one, you can capture and respond to those changes. This is particularly useful when working with complex data structures where changes can occur at various levels of nesting.

Leveraging Proxies for Deep Observation

To implement deep observation in TypeScript, we can leverage the power of proxies. Proxies are a feature introduced in ECMAScript 6 that allows you to intercept and customise an object’s behaviour. By creating a proxy for an object, you can define traps that intercept and handle operations such as property access and modification.

Implementing the Deep Observer

Let’s dive into the implementation of the deep observer in TypeScript. We’ll start by defining a Callback type that represents the function to be called whenever a property is modified:

type Callback = (obj: T, key: keyof T, value: any) => void;

The Callback type is now generic, taking a type parameter T that represents the type of the observed object. This allows for better type safety and inference when defining the callback function.

Next, we’ll create the observe function that takes an object and a callback function and returns a new proxy for that object:

function observe(obj: T, callback: Callback): T {
  const proxyCache = new WeakMap();

  function createProxy(target: T): T {
    if (proxyCache.has(target)) {
      return proxyCache.get(target)!;
    }

    const proxy: T = new Proxy(target, {
      set(target, key, value, receiver) {
        if (typeof value === 'object' && value !== null) {
          value = createProxy(value as T);
        }
        const result = Reflect.set(target, key, value, receiver);
        if (result) {
          callback(target, key as keyof T, value);
        }
        return result;
      },
      get(target, key, receiver) {
        const value = Reflect.get(target, key, receiver);
        if (typeof value === 'object' && value !== null && !proxyCache.has(value)) {
          return createProxy(value as T);
        }
        return value;
      },
    });

    proxyCache.set(target, proxy);
    return proxy;
  }

  return createProxy(obj);
}

We create a WeakMap called proxyCache to cache the created proxies. This avoids creating multiple proxies for the same object, which can lead to unexpected behaviour. The WeakMap uses the original object as the key and the corresponding proxy as the value.

Inside the observe function, we define the createProxy function that takes the target object of type T and returns its proxy. Before creating a new proxy, it checks if a proxy for the target object already exists in the proxyCache. If it does, it returns the cached proxy instead of creating a new one.

The set trap uses Reflect.set to set the value on the target object, ensuring that the default behaviour is preserved. It also checks the result of Reflect.set before invoking the callback function to ensure that the property was successfully set.

The get trap uses Reflect.get to retrieve the value from the target object. If the value is an object and doesn’t have a corresponding proxy in the proxyCache, it calls createProxy to create a new proxy for the nested object.

After creating the proxy, it is stored in the proxyCache using proxyCache.set(target, proxy). This ensures that subsequent accesses to the same object will reuse the existing proxy.

Usage Example

Now, let’s see how we can use the deep observer in action:

interface Person {
  name: string;
  age: number;
  address: {
    street: string;
    city: string;
  };
}

const data: Person = {
  name: 'John',
  age: 30,
  address: {
    street: '123 Main St',
    city: 'New York',
  },
};

const observedData = observe(data, (obj, key, value) => {
  console.log(\`Property "${key}" changed to:\`, value);
});

observedData.name = 'Jane'; // Logs: Property "name" changed to: Jane
observedData.age = 31; // Logs: Property "age" changed to: 31
observedData.address.city = 'Los Angeles'; // Logs: Property "city" changed to: Los Angeles

In the usage example, we define an interface Person to represent the structure of the observed object. We then create an instance of Person called data and pass it along with a callback to the function.

The observe function infers the type of data as Person based on the provided type annotation. This ensures that the obj parameter in the callback function is of type Person, and the key parameter is of type keyof Person, providing strong typing and type safety.

Conclusion

Creating a deep observer in TypeScript using proxies provides a powerful way to track changes in objects, including nested properties. By leveraging proxies and incorporating the suggested improvements, we can enhance the deep observation implementation’s type, safety, performance, and correctness.

Feel free to experiment with the improved deep observer implementation and explore further possibilities of proxies in your TypeScript projects.