Getters, setters, Proxy and Reflect

Run code when a property is read or written, from simple computed properties to proxies that intercept every operation on an object.

  • Beginner
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Define getters and setters
  • Validate writes with a Proxy
  • Forward default behaviour with Reflect
  • Recognise where proxies break

Before you start

  • Classes
  • Comfortable with classes and private fields

Normally, reading a property returns a stored value and writing one stores a value. Sometimes you want code to run instead: to compute a value, to validate input, or to react to changes. Getters and setters do this for named properties you know in advance. A Proxy does it for every operation on an object, including properties that do not exist yet.

Getters and setters

A getter is a method that runs when a property is read. A setter runs when it is assigned. From the outside they look like ordinary properties.

JavaScript
const course = {
  title: 'JavaScript',
  lessons: 54,
  completed: 27,

  get progress() {
    return Math.round((this.completed / this.lessons) * 100);
  },
};

console.log(course.progress); // → 50
course.completed = 54;
console.log(course.progress); // → 100

progress is always up to date because it is computed on each read. There is no stored value to fall out of sync.

In a class, a setter pairs naturally with a private field for validation:

JavaScript
class Temperature {
  #celsius = 0;

  get celsius() {
    return this.#celsius;
  }

  set celsius(value) {
    if (typeof value !== 'number' || Number.isNaN(value)) {
      throw new TypeError(`celsius must be a number, received ${String(value)}`);
    }
    this.#celsius = value;
  }

  get fahrenheit() {
    return this.#celsius * 9 / 5 + 32;
  }
}

const temp = new Temperature();
temp.celsius = 20;
console.log(temp.fahrenheit); // → 68

try {
  temp.celsius = 'warm';
} catch (error) {
  console.log(error.message); // → celsius must be a number, received warm
}

fahrenheit has a getter but no setter, so it is read-only. Assigning to it throws a TypeError in strict mode, which applies inside classes and ES modules; in sloppy-mode scripts the assignment is silently ignored.

You can also add accessors to an existing object with Object.defineProperty:

JavaScript
const user = { first: 'Ada', last: 'Lovelace' };

Object.defineProperty(user, 'fullName', {
  get() {
    return `${this.first} ${this.last}`;
  },
  enumerable: false,
});

console.log(user.fullName);     // → Ada Lovelace
console.log(Object.keys(user)); // → [ 'first', 'last' ]

Keep getters cheap and free of side effects. Code that reads obj.total does not expect a network request or a changed object. If a value is expensive to compute, use a method such as calculateTotal() so the cost is visible at the call site.

Proxy: intercepting operations

new Proxy(target, handler) creates an object that stands in front of target. Operations on the proxy are passed to traps on the handler if they exist, and go straight to the target if they do not.

JavaScript
const target = { title: 'JavaScript' };

const logged = new Proxy(target, {
  get(obj, key, receiver) {
    console.log(`read ${String(key)}`);
    return Reflect.get(obj, key, receiver);
  },
});

console.log(logged.title);
// → read title
// → JavaScript

The common traps:

TrapIntercepts
getreading proxy.key or proxy[key]
setassigning proxy.key = value
hasthe in operator
deletePropertydelete proxy.key
ownKeysObject.keys, for...in, Reflect.ownKeys and similar
applycalling the proxy, when the target is a function
constructnew proxy(), when the target is a constructor

String(key) is used in the log because keys can be symbols, and a symbol cannot be converted to a string with a template literal directly.

Reflect: the default behaviour

Inside a trap, you usually want to do something extra and then carry on as normal. The Reflect object has one method per trap, with the same arguments, that performs the default operation:

TrapDefault behaviour
get(obj, key, receiver)Reflect.get(obj, key, receiver)
set(obj, key, value, receiver)Reflect.set(obj, key, value, receiver)
has(obj, key)Reflect.has(obj, key)
deleteProperty(obj, key)Reflect.deleteProperty(obj, key)

Passing receiver through matters when the target has getters or setters: it makes this inside them refer to the proxy, so their own property reads also go through your traps.

Validating writes

A validation proxy checks every assignment, including properties added later:

JavaScript
function createCourse(data) {
  const rules = {
    title: (v) => typeof v === 'string' && v.trim() !== '',
    lessons: (v) => Number.isInteger(v) && v > 0,
    level: (v) => ['beginner', 'intermediate', 'advanced'].includes(v),
  };

  return new Proxy(data, {
    set(obj, key, value, receiver) {
      if (!(key in rules)) {
        throw new TypeError(`Unknown course property "${String(key)}"`);
      }
      if (!rules[key](value)) {
        throw new TypeError(`Invalid value for ${String(key)}: ${JSON.stringify(value)}`);
      }
      return Reflect.set(obj, key, value, receiver);
    },
    deleteProperty(obj, key) {
      throw new TypeError(`Cannot delete course property "${String(key)}"`);
    },
  });
}

const course = createCourse({ title: 'CSS', lessons: 34, level: 'beginner' });

course.lessons = 35;        // Fine.
console.log(course.lessons); // → 35

try {
  course.lessons = -1;
} catch (error) {
  console.log(error.message); // → Invalid value for lessons: -1
}

try {
  course.lesons = 40; // Typo.
} catch (error) {
  console.log(error.message); // → Unknown course property "lesons"
}

A set trap must return true when the write succeeded. If it returns false, strict-mode code throws a generic TypeError. Throwing your own error with a clear message, as above, is friendlier. Note that the proxy only checks writes made after it was created; the initial data is not validated, so validate it separately if it can be wrong.

Reacting to changes

The same idea underlies reactive state in many UI libraries: a set trap notices a change and tells interested code.

JavaScript
function observable(target, onChange) {
  return new Proxy(target, {
    set(obj, key, value, receiver) {
      const old = obj[key];
      const ok = Reflect.set(obj, key, value, receiver);
      if (ok && old !== value) {
        onChange(key, value, old);
      }
      return ok;
    },
  });
}

const filters = observable({ topic: 'all', search: '' }, (key, value) => {
  console.log(`${key} changed to "${value}"`);
});

filters.search = 'grid';  // → search changed to "grid"
filters.search = 'grid';  // Same value: nothing logged.
filters.topic = 'CSS';    // → topic changed to "CSS"

On the course dashboard, onChange could re-render the list whenever a filter changes. This proxy is shallow: changing filters.nested.value would not be noticed, because reading filters.nested returns the plain inner object.

Where proxies break

Proxies are powerful, but they have sharp edges:

  • Identity. A proxy is a different object from its target: proxy === target is false. Code that stores the target in a Set will not find the proxy in it.
  • Private fields and internal slots. Methods that use private fields, or built-ins with internal state such as Map, Set and Date, fail when this is the proxy:
JavaScript
class Counter {
  #count = 0;
  increment() {
    this.#count++;
    return this.#count;
  }
}

const proxied = new Proxy(new Counter(), {});
try {
  proxied.increment();
} catch (error) {
  console.log(error instanceof TypeError); // → true
}

const map = new Proxy(new Map(), {});
try {
  map.set('a', 1);
} catch (error) {
  console.log(error instanceof TypeError); // → true
}

this inside increment is the proxy, and the proxy has no #count. The usual fix is a get trap that binds methods to the target, which in turn bypasses your traps for anything those methods do.

  • Invariants. The engine enforces some rules, for example a get trap cannot report a different value for a non-writable, non-configurable property. Breaking one throws a TypeError.
  • Performance. Every intercepted operation runs a function. Proxies are fine for configuration, validation at boundaries and development tools, but avoid them in hot loops.
  • Debugging. Behaviour that hides behind ordinary-looking property access surprises readers. Use a proxy when the interception is the point, and document it.

Proxy.revocable(target, handler) returns { proxy, revoke }. After revoke(), any operation on the proxy throws, which is a way to hand out temporary access to an object.

Summary

  • Getters and setters run code when a known property is read or written; use them for computed values and validation.
  • A Proxy intercepts operations such as get, set, has and deleteProperty on any property, including new ones.
  • Use Reflect methods inside traps to perform the default behaviour, passing receiver through.
  • A set trap must return true on success; throw a clear error yourself when rejecting a value.
  • Proxies break private fields and built-ins with internal state, change identity and cost performance.

Further reading