TypeScript’s Proxy and Reflect APIs let you intercept and redefine fundamental operations on JavaScript objects, effectively enabling metaprogramming with type safety.

Imagine you have a plain JavaScript object:

const user = {
  name: 'Alice',
  age: 30,
  isActive: true
};

Now, let’s create a Proxy for this user object. A Proxy is essentially a wrapper that can intercept operations like getting or setting properties. We define "traps" within the handler object that specify how these operations should behave.

const handler = {
  get(target: object, property: string | symbol, receiver: object): any {
    console.log(`Getting property "${String(property)}"`);
    return Reflect.get(target, property, receiver);
  },
  set(target: object, property: string | symbol, value: any, receiver: object): boolean {
    console.log(`Setting property "${String(property)}" to "${value}"`);
    return Reflect.set(target, property, value, receiver);
  }
};

const proxiedUser = new Proxy(user, handler);

When you interact with proxiedUser:

console.log(proxiedUser.name); // Output: Getting property "name" -> Alice
proxiedUser.age = 31;        // Output: Setting property "age" to "31"
console.log(proxiedUser.age);  // Output: Getting property "age" -> 31

You can see the get and set traps logging their activity. Reflect provides default implementations for these fundamental operations, allowing us to augment them without completely rewriting the logic.

The real power comes when you combine this with TypeScript’s type system. Let’s define an interface for our user:

interface User {
  name: string;
  age: number;
  isActive: boolean;
}

Now, we want our Proxy to enforce these types. We can leverage generic types and conditional types to achieve this.

type ProxyHandler<T extends object> = {
  get?(target: T, property: keyof T, receiver: T): T[keyof T];
  set?(target: T, property: keyof T, value: T[keyof T], receiver: T): boolean;
  // ... other traps
};

function createTypeSafeProxy<T extends object>(target: T, handler: ProxyHandler<T>): Proxy<T> {
  return new Proxy(target, handler) as Proxy<T>;
}

const typedHandler: ProxyHandler<User> = {
  get(target, property, receiver) {
    console.log(`Accessing ${String(property)}`);
    return Reflect.get(target, property, receiver);
  },
  set(target, property, value, receiver) {
    console.log(`Setting ${String(property)} to ${value}`);
    // Basic type check for demonstration
    if (property === 'age' && typeof value !== 'number') {
      throw new Error('Age must be a number');
    }
    return Reflect.set(target, property, value, receiver);
  }
};

const userObject: User = {
  name: 'Bob',
  age: 25,
  isActive: false
};

const proxiedTypedUser = createTypeSafeProxy(userObject, typedHandler);

console.log(proxiedTypedUser.name); // Output: Accessing name -> Bob

try {
  // @ts-expect-error - This will cause a TypeScript error at compile time IF you don't cast
  // but the runtime check is what we're demonstrating here.
  proxiedTypedUser.age = 'twenty-six' as any;
} catch (e: any) {
  console.error(e.message); // Output: Age must be a number
}

proxiedTypedUser.age = 26; // Output: Setting age to 26
console.log(proxiedTypedUser.age); // Output: Accessing age -> 26

Here, keyof T ensures that the property argument in our traps is a valid key of the target object T. The return type of get and the type of value in set are constrained to T[keyof T], which represents any valid value type for the object.

You can implement more sophisticated checks within your traps. For instance, you could validate that a number property is within a specific range or that a string property matches a regex.

Consider a scenario where you want to automatically serialize and deserialize properties when they are accessed or modified, perhaps for persistent storage.

interface Config {
  timeout: number;
  retries: number;
}

const defaultConfig: Config = {
  timeout: 5000,
  retries: 3
};

const localStorageHandler: ProxyHandler<Config> = {
  get(target, property, receiver) {
    const storedValue = localStorage.getItem(String(property));
    if (storedValue !== null) {
      try {
        return JSON.parse(storedValue);
      } catch (e) {
        console.error(`Failed to parse stored value for ${String(property)}:`, e);
        // Fallback to default if parsing fails
        return Reflect.get(target, property, receiver);
      }
    }
    // If not in localStorage, return the default value
    return Reflect.get(target, property, receiver);
  },
  set(target, property, value, receiver) {
    localStorage.setItem(String(property), JSON.stringify(value));
    return Reflect.set(target, property, value, receiver);
  }
};

const proxiedConfig = createTypeSafeProxy(defaultConfig, localStorageHandler);

// Simulate setting values
proxiedConfig.timeout = 10000; // Sets to localStorage
proxiedConfig.retries = 5;     // Sets to localStorage

// Simulate retrieving values
console.log(proxiedConfig.timeout); // Reads from localStorage, outputs 10000
console.log(proxiedConfig.retries); // Reads from localStorage, outputs 5

// Clear localStorage to demonstrate fallback
localStorage.clear();
console.log(proxiedConfig.timeout); // Reads from default, outputs 5000

The most surprising thing about Proxy and Reflect is that they allow you to intercept operations that JavaScript itself performs, like property access (get, set), function invocation (apply), and even object creation (construct), all while maintaining a degree of type safety through TypeScript’s generics and interfaces. This isn’t just about adding behavior; it’s about fundamentally changing how an object responds to core language constructs.

The Proxy object is immutable once created. If you need to change the traps or the target object, you must create a new Proxy instance.

The next concept you’ll likely encounter is using Proxy for advanced features like creating observable data structures or implementing lazy loading of properties.

Want structured learning?

Take the full Typescript course →