Skip to content
JavaScript Basics: Make Object Operations Explicit with Reflect

JavaScript Basics: Make Object Operations Explicit with Reflect

October 22, 2026

The Reflect object exposes operations such as reading, writing, and construction as functions. That is useful when keys or constructors are selected at runtime, or when a helper needs to distinguish a refused change from a successful one. It is a collection of static methods, not something to call with new.

These three independent examples were tested on Linux with Node.js v24.19.0. They use only built-in features, make no network requests, and include assertions. The exact files require Node.js 15.0.0 or later because they import node:assert/strict; the submodule was introduced in Node.js 15. That historical minimum is not a recommendation to use an obsolete release: use a currently supported Node.js version. Only the installed version above was executed for this tutorial.

Prepare a scratch directory

You need Bash, a text editor, Node.js on your PATH, and the mktemp, rm, and rmdir utilities. Keep the same terminal open. Save each named source file as UTF-8 in this directory; .mjs selects an ES module. The guarded commands stop if creating or entering the scratch directory fails.

node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

The tested runtime prints:

v24.19.0

1. Check whether a property change succeeded

Save this as check-reflect-writes.mjs:

import assert from 'node:assert/strict';

const settings = Object.preventExtensions({ theme: 'dark' });
const changed = Reflect.set(settings, 'theme', 'light');
const added = Reflect.set(settings, 'fontSize', 16);
assert.equal(changed, true);
assert.equal(added, false);
assert.equal(settings.theme, 'light');
assert.equal(settings.fontSize, undefined);

const removed = Reflect.deleteProperty(settings, 'theme');
assert.equal(removed, true);
assert.equal(Reflect.deleteProperty(settings, 'missing'), true);
assert.deepEqual(Object.keys(settings), []);
assert.throws(() => Reflect.set(null, 'theme', 'dark'), TypeError);
console.log(`Existing property updated: ${changed}`);
console.log(`New property added: ${added}`);
console.log(`Existing property removed: ${removed}`);

Run it:

node check-reflect-writes.mjs

Output:

Existing property updated: true
New property added: false
Existing property removed: true

Preventing extensions blocks new own properties, but does not freeze an existing writable or configurable property. The results show each distinction: changing theme succeeds, adding fontSize fails, and deleting theme succeeds. Deleting an already absent property also counts as success.

Reflect.set() and Reflect.deleteProperty() report these ordinary refusals with false, even inside a strict-mode module. They do not promise to suppress every error: a non-object target throws, as can user code inside a setter or proxy trap. Check the result rather than assuming that lack of an exception means the change happened. See the Reflect reference.

2. Choose the receiver for an accessor

Save this as choose-accessor-receiver.mjs:

import assert from 'node:assert/strict';

const pricing = {
  quantity: 1,
  unitPrice: 10,
  get total() { return this.quantity * this.unitPrice; },
  set total(value) { this.unitPrice = value / this.quantity; },
};
const quote = { quantity: 4, unitPrice: 3 };
assert.equal(Reflect.get(pricing, 'total'), 10);
assert.equal(Reflect.get(pricing, 'total', quote), 12);
const updated = Reflect.set(pricing, 'total', 40, quote);
assert.equal(updated, true);
assert.equal(quote.unitPrice, 10);
assert.equal(pricing.quantity, 1);
assert.equal(pricing.unitPrice, 10);
assert.equal(Reflect.get(pricing, 'total', quote), 40);
console.log(`Original total: ${pricing.total}`);
console.log(`Quote total: ${Reflect.get(pricing, 'total', quote)}`);

Run it:

node choose-accessor-receiver.mjs

Output:

Original total: 10
Quote total: 40

The target tells Reflect.get() where to look for the property. The optional receiver supplies this if that lookup calls a getter. For Reflect.set(), the fourth argument similarly supplies the receiver for a setter. Here the accessor is found on pricing, but the setter updates quote.unitPrice.

This separation matters when forwarding proxy operations or deliberately borrowing accessor behavior. An ordinary data property still returns its stored value; supplying a receiver does not redirect the whole lookup to another object. The example assumes a positive quantity and a numeric total; a real pricing API should validate those inputs separately. See Reflect.get and Reflect.set in the specification.

3. Construct an object from a trusted registry

Save this as construct-registered-report.mjs:

import assert from 'node:assert/strict';

class Report {
  constructor(title, pages) {
    if (!Number.isInteger(pages) || pages < 1) {
      throw new RangeError('pages must be a positive integer');
    }
    this.title = title;
    this.pages = pages;
  }
}
const constructors = new Map([['report', Report]]);
function create(kind, args) {
  const Constructor = constructors.get(kind);
  if (Constructor === undefined) throw new RangeError('unknown document kind');
  return Reflect.construct(Constructor, args);
}
const report = create('report', ['Release notes', 4]);
assert.equal(report instanceof Report, true);
assert.deepEqual([report.title, report.pages], ['Release notes', 4]);
assert.throws(() => create('unknown', []), RangeError);
assert.throws(() => create('report', ['Empty', 0]), RangeError);
assert.throws(() => Reflect.construct(() => {}, []), TypeError);
console.log(`${report.title}: ${report.pages} pages`);
console.log('Unknown kinds and invalid constructors rejected');

Run it:

node construct-registered-report.mjs

Output:

Release notes: 4 pages
Unknown kinds and invalid constructors rejected

Reflect.construct() passes an argument list to a constructor. With two arguments, it gives the same construction behavior as new Constructor(...args). The registry makes the supported document kinds explicit; there is no eval() or arbitrary lookup on the global object.

Constructor validation still runs, and constructor exceptions propagate. An arrow function is callable but not constructible, so it cannot be used here. Prefer ordinary new Report(...) when the constructor and arguments are already known; this form helps generic factories. The optional third newTarget argument changes more advanced construction details and is intentionally outside this example. See Reflect.construct.

Clean up

In the same terminal, remove only the named files, return to the original directory, and remove the now-empty scratch directory:

rm -- check-reflect-writes.mjs choose-accessor-receiver.mjs construct-registered-report.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on