Intercepting Object Operations

Introduction: Why Intercept Object Operations?

Welcome to the first lesson of our course on Meta-Programming and Advanced Integration Patterns. In this course, we are going to explore how to write code that treats other code as data. This is a powerful skill that allows you to create more flexible and dynamic applications.

In standard programming, we usually interact with objects directly. We read a property, change a value, or delete a key. Meta-programming changes this by allowing us to step in the middle of these actions. This is called "interception." By intercepting these operations, we can add extra logic without changing the original object or the code that uses it.

In the real world, this is very useful for several reasons. You might want to log every time a piece of data is changed for debugging purposes. You could also use it to validate data, ensuring that a user does not set a price property to a negative number. It is also great for access control, where you can prevent certain parts of your program from reading sensitive information. In this lesson, we will focus on building a logging tool that tracks everything happening to an object.

The Proxy Object: Wrapping A Target

To start intercepting operations, JavaScript provides us with a special object called a Proxy. You can think of a Proxy as a wrapper or a "middleman" for another object. The object being wrapped is called the target. Once an object is wrapped, any interaction with that object must go through the Proxy first.

To create a Proxy, we use the new Proxy(target, handler) syntax. The target is the original object we want to watch. The handler is another object that contains our "traps."

const user = { name: "Alice" };
const handler = {}; // No traps defined yet
const proxyUser = new Proxy(user, handler);

console.log(proxyUser.name); // Outputs: Alice

If we don't put any traps in the handler, the Proxy will just pass everything through to the target as if it weren't there.

Intercepting Property Access With Get And Set Traps

The most common things we do with objects are reading and writing values. The get trap intercepts any attempt to read a property. The set trap intercepts any attempt to change a property value.

const handler = {
  get(target, prop) {
    console.log(`Property "${String(prop)}" was accessed.`);
    return target[prop];
  },
  set(target, prop, value) {
    if (prop === "age" && value < 0) {
      // Reject the write by throwing. See the note below on why we
      // throw instead of returning false.
      throw new TypeError("Age cannot be negative!");
    }
    target[prop] = value;
    return true;
  }
};

const proxy = new Proxy({ age: 25 }, handler);
console.log(proxy.age); // Logs access, then prints 25
proxy.age = -5;         // Throws TypeError: Age cannot be negative!

The get trap receives the target and the prop. The set trap additionally receives the value someone is trying to save. By using these traps, we can see exactly what is being read and what is being changed in real-time.

A quick but important detail: a set trap must return a boolean. Returning true means "the write succeeded," and returning false means "the write was rejected." However, in strict mode — which is always on inside JavaScript modules and any file with "use strict" — returning false from a set trap does not fail quietly. Instead, the assignment expression throws a TypeError. Because all the code in this course runs as strict-mode module code, a rejected assignment will surface as a thrown error. For that reason, when we want to reject a write with a clear, custom message, we prefer to throw new TypeError(...) explicitly, as shown above. We still return true for writes that we want to allow.

Notice that we wrap the property key with String(prop) before logging it. A property key can be a string or a Symbol, and string methods are not available on symbols. Converting with String(prop) keeps our traps safe no matter what kind of key is used.

More Useful Traps: Has And DeleteProperty

Beyond reading and writing, we often check if a property exists or try to remove one. The has trap intercepts the in operator, while the deleteProperty trap intercepts the delete keyword.

const handler = {
  has(target, prop) {
    // Guard with a typeof check: only strings have .startsWith,
    // and a property key could be a Symbol.
    if (typeof prop === "string" && prop.startsWith("_")) return false; // Hide "private" keys
    return prop in target;
  },
  deleteProperty(target, prop) {
    console.log(`Attempting to delete: ${String(prop)}`);
    return delete target[prop];
  }
};

const data = new Proxy({ _id: 101, status: "active" }, handler);
console.log("_id" in data); // false (hidden by trap)
delete data.status;         // Logs attempt and deletes property

These traps give you a high level of control over the lifecycle of an object's data, allowing you to make certain properties "permanent" or invisible. Notice that we guard the prop.startsWith call with a typeof prop === "string" check and convert keys with String(prop) before logging — this keeps the traps robust when a key happens to be a Symbol.

Blocking Property Definitions With DefineProperty

There is one more trap that is closely related to set: the defineProperty trap. While the set trap intercepts normal assignments like obj.x = 1, the defineProperty trap intercepts calls to Object.defineProperty(obj, key, descriptor). This is the lower-level API that defines a property along with its descriptor — settings such as whether the property is writable, enumerable, or configurable.

Intercepting defineProperty is useful when you want to fully lock down an object. A determined caller could try to bypass your set trap by calling Object.defineProperty directly, so to build a truly read-only wrapper you must handle this trap too.

const handler = {
  defineProperty(target, prop, descriptor) {
    console.log(`Attempting to define: ${String(prop)}`);
    return false; // In strict mode, this rejection throws a TypeError
  }
};

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

// This call routes through the defineProperty trap:
Object.defineProperty(locked, "x", { value: 1 });

The trap receives the target, the property key, and the descriptor object that describes how the property should be defined. Like set, it must return a boolean indicating whether the definition was allowed. Here we simply return false instead of throwing our own error, and this is deliberate: it shows the strict-mode behavior described earlier — a rejected defineProperty automatically throws a TypeError even when you only return false. We throw explicitly (as in the set example) only when we want to attach a clear, custom message; when a plain rejection is enough, returning false is perfectly fine. You will use this trap in the practice exercises to build an object that cannot be modified in any way.

The Reflect API: Your Default Forwarder

Putting It All Together: A Logging Proxy

Summary And Practice Preparation

In this lesson, we introduced the core concepts of meta-programming using the Proxy and Reflect tools. You learned that a Proxy acts as a middleman that uses traps to intercept actions such as reading, writing, and deleting. You also learned that Reflect is the best way to handle the default behavior inside those traps to keep your code working as expected.

By understanding these patterns, you can now build systems that monitor, protect, or transform data automatically. In the upcoming practice exercises, you will get hands-on experience writing your own traps and using the Reflect API to manage object behavior. Get ready to dive into the code!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal