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<T> = (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<T extends object>(obj: T, callback: Callback<T>): T { const proxyCache = new WeakMap<object, T>(); 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.