Sometimes we need more control over what happens when a property is read or written. Maybe we want to validate data before it’s stored, compute a value on the fly, or log every property access. JavaScript gives us three tools for this: getters/setters, Object.defineProperty, and Proxy.
Getters and Setters in Object Literals
A getter runs when we read a property. A setter runs when we assign to it. They look like regular properties from the outside, but they’re actually function calls under the hood.
const user = {
firstName: "Manish",
lastName: "Prajapati",
get fullName() {
return `${this.firstName} ${this.lastName}`; // computed on access
},
set fullName(value) {
const [first, last] = value.split(" ");
this.firstName = first;
this.lastName = last;
}
};
console.log(user.fullName); // "Manish Prajapati" (calls getter)
user.fullName = "Tony Stark"; // calls setter
console.log(user.firstName); // "Tony"
We access fullName like a normal property — no parentheses needed. That’s the whole point: it feels like data, but it’s actually logic.
Getters and Setters in Classes
Same idea, but inside a class:
class Temperature {
#celsius; // private field
constructor(celsius) {
this.#celsius = celsius;
}
get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
set fahrenheit(f) {
this.#celsius = (f - 32) * 5 / 9;
}
}
const temp = new Temperature(100);
console.log(temp.fahrenheit); // 212 (computed from celsius)
temp.fahrenheit = 32; // sets celsius to 0
This is great for validation too — we can check values in the setter and throw if they’re invalid.
Object.defineProperty
For more control, Object.defineProperty lets us add getters/setters to existing objects and configure whether properties are enumerable, writable, etc.
const account = { _balance: 0 };
Object.defineProperty(account, "balance", {
get() { return this._balance; },
set(value) {
if (value < 0) throw new Error("Balance can't be negative");
this._balance = value;
},
enumerable: true
});
account.balance = 100; // works
// account.balance = -50; // Error: Balance can't be negative
This is lower-level than class syntax, but useful when we need to add accessors dynamically or configure property descriptors.
Enter the Proxy
Proxy is a whole different level. It wraps an entire object and intercepts any operation on it — not just specific properties. Think of it as a security guard that sits between the outside world and our object.
const user = { name: "Manish", age: 25 };
const proxy = new Proxy(user, {
get(target, prop) {
console.log(`Reading ${prop}`);
return target[prop];
},
set(target, prop, value) {
console.log(`Setting ${prop} to ${value}`);
target[prop] = value;
return true; // must return true for success
}
});
proxy.name; // logs: Reading name → "Manish"
proxy.age = 26; // logs: Setting age to 26
A Proxy takes two arguments: the target (the original object) and a handler (an object with “trap” functions that intercept operations).
Common Proxy Traps
Here are the traps we’ll use most often:
| Trap | Intercepts | Example Use |
|---|---|---|
get | Reading a property | Default values, logging |
set | Writing a property | Validation, reactive updates |
has | The in operator | Hide certain properties |
deleteProperty | The delete operator | Prevent deletion |
const schema = {
name: "string",
age: "number"
};
const validated = new Proxy({}, {
set(target, prop, value) {
if (schema[prop] && typeof value !== schema[prop]) {
throw new TypeError(`${prop} must be a ${schema[prop]}`);
}
target[prop] = value;
return true;
}
});
validated.name = "Manish"; // works
validated.age = 25; // works
// validated.age = "old"; // TypeError: age must be a number
The Reflect API
Reflect provides default behavior for all the proxy traps. Instead of doing target[prop] ourselves, we use Reflect.get(target, prop). This matters because Reflect methods handle edge cases (like getters with the right this) that manual access can miss.
const logged = new Proxy({}, {
get(target, prop, receiver) {
console.log(`Accessed: ${String(prop)}`);
return Reflect.get(target, prop, receiver); // proper default behavior
},
set(target, prop, value, receiver) {
console.log(`Set: ${String(prop)} = ${value}`);
return Reflect.set(target, prop, value, receiver);
}
});
The rule of thumb: inside proxy traps, use Reflect methods instead of direct object operations.
Practical Use Cases
Reactive objects — This is how Vue 3’s reactivity works. A Proxy detects when data changes and triggers re-renders.
Auto-populating defaults:
const withDefaults = new Proxy({}, {
get(target, prop) {
if (!(prop in target)) {
target[prop] = []; // auto-create empty array for missing keys
}
return target[prop];
}
});
withDefaults.users.push("Manish"); // no need to initialize users first
console.log(withDefaults.users); // ["Manish"]
Negative array indexing:
const arr = new Proxy([10, 20, 30], {
get(target, prop) {
const index = Number(prop);
if (index < 0) return target[target.length + index]; // arr[-1] = last element
return Reflect.get(target, prop);
}
});
console.log(arr[-1]); // 30
console.log(arr[-2]); // 20
Proxy vs Getters/Setters
| Getters/Setters | Proxy | |
|---|---|---|
| Scope | Specific properties only | All properties, including ones that don’t exist yet |
| Performance | Faster (no indirection layer) | Slight overhead |
| Use case | Computed values, simple validation | Dynamic behavior, meta-programming |
| Syntax | Built into classes/objects | Wraps an existing object |
Use getters/setters when we know exactly which properties need special behavior. Use Proxy when we need to intercept everything dynamically.
In simple language, getters and setters let us run code when a specific property is read or written — great for validation and computed values. Proxy goes further and intercepts every operation on an entire object, which is what powers frameworks like Vue 3 under the hood.