The Secrets of Proxies: Intercepting and Controlling Objects in JavaScript

Behind the scenes of JavaScript objects lies a powerful tool that allows you to intercept, modify, and control virtually any interaction with objects. Welcome to the world of Proxies. Introduction: What Are Proxies? In JavaScript, Proxies act as intermediaries for objects, allowing you to customize fundamental behaviors such as property reading, value assignment, enumeration, function invocation, and even operations with the new operator. Introduced in ES6 (ECMAScript 2015), Proxies remain underutilized by many developers, despite offering impressive possibilities. A Proxy wraps a target object and defines traps that are triggered when specific operations are performed on the object. These traps allow you to intercept and customize JavaScript's default behavior. Anatomy of a Proxy The basic structure of a Proxy consists of two main components: const proxy = new Proxy(target, handler); Target: The original object to be wrapped by the proxy Handler: An object containing the "traps" that define the customized behavior Essential Traps JavaScript offers 13 different traps, but let's focus on the most powerful ones: 1. get The get trap intercepts property reads. It's triggered whenever you access an object property. const handler = { get(target, prop, receiver) { console.log(`Accessing property: ${prop}`); return Reflect.get(target, prop, receiver); } }; const user = { name: "Anna", age: 28 }; const userProxy = new Proxy(user, handler); console.log(userProxy.name); // Output: // Accessing property: name // Anna 2. set The set trap is triggered when values are assigned to properties. const handler = { set(target, prop, value, receiver) { if (prop === 'age' && typeof value !== 'number') { throw new TypeError('Age must be a number'); } console.log(`Setting ${prop} = ${value}`); return Reflect.set(target, prop, value, receiver); } }; const userProxy = new Proxy({}, handler); userProxy.name = "Charles"; // Setting name = Charles userProxy.age = 30; // Setting age = 30 try { userProxy.age = "thirty"; // Error! } catch (e) { console.log(e.message); // Age must be a number } 3. apply Intercepts function calls, allowing you to transform how a function is executed. function add(a, b) { return a + b; } const handler = { apply(target, thisArg, args) { console.log(`Calling function with arguments: ${args}`); return Reflect.apply(target, thisArg, args); } }; const addProxy = new Proxy(add, handler); console.log(addProxy(5, 3)); // Output: // Calling function with arguments: 5,3 // 8 4. construct Intercepts the new operator, allowing you to control how objects are instantiated. class Person { constructor(name) { this.name = name; } } const handler = { construct(target, args, newTarget) { console.log(`Creating new instance with: ${args}`); return Reflect.construct(target, args, newTarget); } }; const PersonProxy = new Proxy(Person, handler); const person = new PersonProxy("Daniel"); // Output: Creating new instance with: Daniel Advanced Use Cases 1. Automatic Property Validation One of the most powerful uses of Proxies is automatic property validation without polluting the original object's code. function createValidator(schema) { return { set(target, prop, value) { if (!schema[prop]) { target[prop] = value; return true; } // Type validation if (schema[prop].type && typeof value !== schema[prop].type) { throw new TypeError(`${prop} must be of type ${schema[prop].type}`); } // Format validation (regex) if (schema[prop].pattern && !schema[prop].pattern.test(value)) { throw new Error(`${prop} does not match the expected pattern`); } // Minimum value validation if (schema[prop].min !== undefined && value = ${schema[prop].min}`); } target[prop] = value; return true; } }; } const userSchema = { name: { type: "string" }, email: { type: "string", pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }, age: { type: "number", min: 18 } }; const user = new Proxy({}, createValidator(userSchema)); user.name = "Laura"; // OK user.age = 25; // OK // user.email = "invalid"; // Error: email does not match the expected pattern // user.age = 15; // Error: age must be >= 18 2. Reactive Objects (Vue.js Style) Vue.js uses a technique similar to Proxies to create its reactivity system. Here's a simplified implementation: function reactive(obj) { const observers = new Map(); return new Proxy(obj, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); // Collect the current dependency if it exists if (activeEffect && typeof value !== 'object') { if (!obser

May 5, 2025 - 12:45
 0
The Secrets of Proxies: Intercepting and Controlling Objects in JavaScript

Behind the scenes of JavaScript objects lies a powerful tool that allows you to intercept, modify, and control virtually any interaction with objects. Welcome to the world of Proxies.

Introduction: What Are Proxies?

In JavaScript, Proxies act as intermediaries for objects, allowing you to customize fundamental behaviors such as property reading, value assignment, enumeration, function invocation, and even operations with the new operator. Introduced in ES6 (ECMAScript 2015), Proxies remain underutilized by many developers, despite offering impressive possibilities.

A Proxy wraps a target object and defines traps that are triggered when specific operations are performed on the object. These traps allow you to intercept and customize JavaScript's default behavior.

Anatomy of a Proxy

The basic structure of a Proxy consists of two main components:

const proxy = new Proxy(target, handler);
  • Target: The original object to be wrapped by the proxy
  • Handler: An object containing the "traps" that define the customized behavior

Essential Traps

JavaScript offers 13 different traps, but let's focus on the most powerful ones:

1. get

The get trap intercepts property reads. It's triggered whenever you access an object property.

const handler = {
  get(target, prop, receiver) {
    console.log(`Accessing property: ${prop}`);
    return Reflect.get(target, prop, receiver);
  }
};

const user = { name: "Anna", age: 28 };
const userProxy = new Proxy(user, handler);

console.log(userProxy.name); 
// Output:
// Accessing property: name
// Anna

2. set

The set trap is triggered when values are assigned to properties.

const handler = {
  set(target, prop, value, receiver) {
    if (prop === 'age' && typeof value !== 'number') {
      throw new TypeError('Age must be a number');
    }
    console.log(`Setting ${prop} = ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

const userProxy = new Proxy({}, handler);

userProxy.name = "Charles"; // Setting name = Charles
userProxy.age = 30;         // Setting age = 30
try {
  userProxy.age = "thirty"; // Error!
} catch (e) {
  console.log(e.message);   // Age must be a number
}

3. apply

Intercepts function calls, allowing you to transform how a function is executed.

function add(a, b) {
  return a + b;
}

const handler = {
  apply(target, thisArg, args) {
    console.log(`Calling function with arguments: ${args}`);
    return Reflect.apply(target, thisArg, args);
  }
};

const addProxy = new Proxy(add, handler);
console.log(addProxy(5, 3));
// Output:
// Calling function with arguments: 5,3
// 8

4. construct

Intercepts the new operator, allowing you to control how objects are instantiated.

class Person {
  constructor(name) {
    this.name = name;
  }
}

const handler = {
  construct(target, args, newTarget) {
    console.log(`Creating new instance with: ${args}`);
    return Reflect.construct(target, args, newTarget);
  }
};

const PersonProxy = new Proxy(Person, handler);
const person = new PersonProxy("Daniel");
// Output: Creating new instance with: Daniel

Advanced Use Cases

1. Automatic Property Validation

One of the most powerful uses of Proxies is automatic property validation without polluting the original object's code.

function createValidator(schema) {
  return {
    set(target, prop, value) {
      if (!schema[prop]) {
        target[prop] = value;
        return true;
      }

      // Type validation
      if (schema[prop].type && typeof value !== schema[prop].type) {
        throw new TypeError(`${prop} must be of type ${schema[prop].type}`);
      }

      // Format validation (regex)
      if (schema[prop].pattern && !schema[prop].pattern.test(value)) {
        throw new Error(`${prop} does not match the expected pattern`);
      }

      // Minimum value validation
      if (schema[prop].min !== undefined && value < schema[prop].min) {
        throw new RangeError(`${prop} must be >= ${schema[prop].min}`);
      }

      target[prop] = value;
      return true;
    }
  };
}

const userSchema = {
  name: { type: "string" },
  email: { 
    type: "string", 
    pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  },
  age: { 
    type: "number", 
    min: 18 
  }
};

const user = new Proxy({}, createValidator(userSchema));

user.name = "Laura";   // OK
user.age = 25;         // OK
// user.email = "invalid";  // Error: email does not match the expected pattern
// user.age = 15;           // Error: age must be >= 18

2. Reactive Objects (Vue.js Style)

Vue.js uses a technique similar to Proxies to create its reactivity system. Here's a simplified implementation:

function reactive(obj) {
  const observers = new Map();

  return new Proxy(obj, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);

      // Collect the current dependency if it exists
      if (activeEffect && typeof value !== 'object') {
        if (!observers.has(prop)) {
          observers.set(prop, new Set());
        }
        observers.get(prop).add(activeEffect);
      }

      return value;
    },

    set(target, prop, value, receiver) {
      const result = Reflect.set(target, prop, value, receiver);

      // Notify observers
      if (observers.has(prop)) {
        observers.get(prop).forEach(effect => effect());
      }

      return result;
    }
  });
}

// Simplified tracking system
let activeEffect = null;

function watchEffect(fn) {
  activeEffect = fn;
  fn();  // Execute the function to track dependencies
  activeEffect = null;
}

// Usage
const state = reactive({ counter: 0 });

watchEffect(() => {
  console.log(`The counter is: ${state.counter}`);
});

// Initial output: The counter is: 0
state.counter++;    // Output: The counter is: 1
state.counter += 2; // Output: The counter is: 3

3. Protection Against Unwanted Modifications

Proxies can protect critical objects against unwanted modifications, as well as detect unauthorized access:

function protectObject(obj, allowedOperations = {}) {
  return new Proxy(obj, {
    get(target, prop) {
      if (allowedOperations.read && !allowedOperations.read.includes(prop)) {
        console.warn(`Unauthorized read of property: ${prop}`);
        return undefined;
      }
      return target[prop];
    },

    set(target, prop, value) {
      if (allowedOperations.write && !allowedOperations.write.includes(prop)) {
        console.error(`Unauthorized attempt to modify: ${prop}`);
        return false;
      }
      target[prop] = value;
      return true;
    },

    deleteProperty(target, prop) {
      console.error(`Attempt to delete property: ${prop}`);
      return false;
    }
  });
}

const secureConfig = protectObject(
  { 
    debug: true, 
    apiKey: 'sk_12345',
    timeout: 30000 
  },
  {
    read: ['debug', 'timeout'],
    write: ['timeout']
  }
);

console.log(secureConfig.debug);   // true
console.log(secureConfig.apiKey);  // Warning and undefined
secureConfig.timeout = 60000;      // Allowed
secureConfig.apiKey = 'new_key';   // Error: Unauthorized attempt

4. Lazy Loading and Property Caching

We can use Proxies to implement lazy loading and caching of computationally expensive properties:

function lazyProperties(initializer) {
  const values = {};
  const computed = {};

  return new Proxy({}, {
    get(target, prop) {
      if (!(prop in values)) {
        // If the property hasn't been calculated yet
        if (prop in initializer) {
          console.log(`Calculating value for ${prop}...`);

          // Memoization: save the result for future use
          values[prop] = initializer[prop]();
          console.log(`Value calculated and cached.`);
        }
      }
      return values[prop];
    }
  });
}

// Usage:
const data = lazyProperties({
  expensiveValue: () => {
    // Simulating an expensive operation
    console.log('Executing time-consuming calculation...');
    let result = 0;
    for (let i = 0; i < 1000000; i++) {
      result += Math.random();
    }
    return result / 1000000;
  },
  anotherExpensiveValue: () => {
    // Another expensive operation
    return fetch('https://api.example.com/data').then(r => r.json());
  }
});

console.log(data.expensiveValue);  // Calculates the first time
console.log(data.expensiveValue);  // Uses cached value

Pitfalls and Performance Considerations

Although powerful, Proxies have some important limitations:

1. Performance Impact

Proxies add a layer of indirection that can affect performance, especially in high-frequency operations:

// Performance test
function testPerformance() {
  const obj = { value: 0 };
  const proxy = new Proxy(obj, {
    get: (target, prop) => Reflect.get(target, prop),
    set: (target, prop, value) => Reflect.set(target, prop, value)
  });

  console.time('Direct Object');
  for (let i = 0; i < 1000000; i++) {
    obj.value++;
  }
  console.timeEnd('Direct Object');

  console.time('Via Proxy');
  for (let i = 0; i < 1000000; i++) {
    proxy.value++;
  }
  console.timeEnd('Via Proxy');
}

testPerformance();
// Typical output:
// Direct Object: ~5ms
// Via Proxy: ~50ms

2. Behavior with Internal Methods

Internal methods and properties of an object can behave unexpectedly when accessed through a proxy:

class MyClass {
  constructor() {
    this.value = 42;
  }

  method() {
    return this.value;
  }
}

const instance = new MyClass();
const proxy = new Proxy(instance, {});

console.log(instance.method()); // 42
console.log(proxy.method());    // Can cause issues if 'this' is used internally

3. Proxies Are Not Transparent for instanceof

class Example {}
const proxy = new Proxy(new Example(), {});

console.log(proxy instanceof Example); // false - this might surprise you!

Integrating Proxies with Other Modern Features

Proxies with Reflect API

The Reflect API was introduced alongside Proxies and provides methods that correspond to the Proxy traps:

const object = { a: 1, b: 2 };
const proxy = new Proxy(object, {
  get(target, prop, receiver) {
    console.log(`Accessing ${prop}`);
    // Using Reflect.get instead of target[prop]
    return Reflect.get(target, prop, receiver);
  }
});

// Reflect also allows metaprogramming operations
console.log(Reflect.ownKeys(proxy)); // ['a', 'b']

Proxies with WeakMap for Private Data

We can combine Proxies with WeakMaps to create truly private properties:

const privateData = new WeakMap();

class SecureUser {
  constructor(name, password) {
    const data = { name, password, passwordAttempts: 0 };
    privateData.set(this, data);

    return new Proxy(this, {
      get(target, prop) {
        if (prop === 'name') {
          return privateData.get(target).name;
        }
        if (prop === 'checkPassword') {
          return password => {
            const data = privateData.get(target);
            data.passwordAttempts++;

            if (data.passwordAttempts > 3) {
              throw new Error('Account locked after multiple attempts');
            }

            return data.password === password;
          };
        }
        return target[prop];
      }
    });
  }
}

const user = new SecureUser('john', 'password123');
console.log(user.name);                // 'john'
console.log(user.checkPassword('wrong')); // false
console.log(user.checkPassword('wrong')); // false
console.log(user.checkPassword('wrong')); // false
// user.checkPassword('wrong');           // Error: Account locked
console.log(user.password);              // undefined - not accessible

Advanced Patterns with Proxies

Deep Proxying

To create fully reactive data structures, we need to apply proxies recursively:

function deepProxy(obj, handler) {
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }

  // Process arrays and objects recursively
  for (const key of Object.keys(obj)) {
    if (typeof obj[key] === 'object' && obj[key] !== null) {
      obj[key] = deepProxy(obj[key], handler);
    }
  }

  return new Proxy(obj, handler);
}

// Handler that logs all operations
const logHandler = {
  get(target, prop, receiver) {
    const value = Reflect.get(target, prop, receiver);
    console.log(`GET: ${prop}`);
    return value;
  },
  set(target, prop, value, receiver) {
    console.log(`SET: ${prop} = ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

const data = deepProxy({
  user: {
    profile: {
      name: 'Anna',
      contact: {
        email: 'anna@example.com'
      }
    },
    preferences: {
      theme: 'dark'
    }
  }
}, logHandler);

// Nested access tracked
data.user.profile.contact.email; // Logs all intermediate accesses

Pattern: Revocable Proxies for Temporary Access Control

JavaScript allows creating revocable proxies, useful for granting temporary access:

function createTemporaryAccess(obj, timeoutMs = 30000) {
  const { proxy, revoke } = Proxy.revocable(obj, {});

  setTimeout(() => {
    console.log('Access automatically revoked');
    revoke();
  }, timeoutMs);

  return proxy;
}

const sensitiveData = { apiKey: '12345', secret: 'confidential value' };
const temporaryAccess = createTemporaryAccess(sensitiveData, 5000);

// Immediate use works
console.log(temporaryAccess.apiKey); // '12345'

// After 5 seconds...
setTimeout(() => {
  try {
    console.log(temporaryAccess.apiKey);
  } catch (e) {
    console.log('Error:', e.message); // TypeError: Cannot perform 'get' on a proxy that has been revoked
  }
}, 6000);

Conclusion:

Proxies represent one of the most powerful additions to modern JavaScript, enabling advanced metaprogramming and transforming how we interact with objects. Frameworks like Vue.js already leverage this technology to create elegant reactive systems.

Final tips for effective use of Proxies:

  1. Use sparingly: Proxies add complexity and can impact performance.
  2. Consider the context: In critical high-performance applications, use Proxies only where the performance cost is acceptable.
  3. Combine with other APIs: Reflect, WeakMap, and Classes work very well with Proxies.
  4. Test thoroughly: Proxy behavior can be subtle, especially with inheritance and internal methods.

By mastering Proxies, you open the door to advanced programming patterns that can make your code more robust, secure, and expressive.