Modern JavaScript Object Features — Descriptors, Immutability, Symbols, and Iterators

A reference tour of the modern object surface — property descriptors, Object.freeze / seal / preventExtensions, Symbol-keyed properties and well-known symbols, the iterable protocol, structuredClone, Object.groupBy, and proxies.

A reference for the parts you reach for once a quarter

This is the fourth and final post on objects. It is a focused reference for the modern, less-frequently-used features that nonetheless matter when they matter: property descriptors, freeze/seal, symbols, the iterable protocol, structuredClone, Object.groupBy, and Proxy.

Series so far: basics · prototypes · classes.

Property descriptors

Every property on every object has a descriptor — a small record that controls how the property behaves.

1const user = { name: "Ali" };
2Object.getOwnPropertyDescriptor(user, "name");
3// { value: "Ali", writable: true, enumerable: true, configurable: true }

The four attributes:

AttributeMeans
valueThe stored value (data descriptors only).
writableCan the value be reassigned with =?
enumerableDoes it appear in for...in and Object.keys?
configurableCan the descriptor itself be changed or deleted?

Accessor descriptors swap value/writable for get/set:

1Object.defineProperty(user, "shouting", {
2  get()        { return this.name.toUpperCase(); },
3  enumerable:  true,
4  configurable: true,
5});

Object.defineProperty is the precision tool when you need a property that hides from iteration, is read-only, or is computed.

Freezing, sealing, preventing extension

Three levels of object lock-down:

MethodAdd props?Remove props?Reassign values?
Object.preventExtensions(obj)NoYesYes
Object.seal(obj)NoNoYes
Object.freeze(obj)NoNoNo
1const config = Object.freeze({ port: 8080 });
2config.port = 9090;          // silently ignored (throws in strict mode)

Symbols

A Symbol is a unique, immutable primitive used as a property key. Symbol-keyed properties do not appear in Object.keys, Object.entries, or for...in — they are effectively hidden from accidental enumeration.

1const id = Symbol("id");
2const user = { name: "Ali", [id]: 42 };
3
4Object.keys(user);          // ["name"]   ← symbol hidden
5user[id];                   // 42

Well-known symbols

The language uses symbols to expose hooks into built-in operations. The most useful:

SymbolLets you override
Symbol.iteratorHow for...of, spread, and destructuring iterate.
Symbol.asyncIteratorHow for await...of iterates.
Symbol.toPrimitiveHow the object converts to string/number/default.
Symbol.toStringTagResult of Object.prototype.toString.call(obj).
Symbol.hasInstanceHow instanceof decides membership.
 1class Money {
 2  constructor(amount, currency) { this.amount = amount; this.currency = currency; }
 3  [Symbol.toPrimitive](hint) {
 4    if (hint === "number") return this.amount;
 5    if (hint === "string") return `${this.currency} ${this.amount}`;
 6    return `${this.amount}`;
 7  }
 8}
 9
10const m = new Money(50, "USD");
11+m;                  // 50
12`${m}`;              // "USD 50"

The iterable protocol

An object is iterable if it has a [Symbol.iterator]() method that returns an iterator — an object with .next() returning { value, done }.

 1const range = {
 2  from: 1,
 3  to:   5,
 4  [Symbol.iterator]() {
 5    let current = this.from;
 6    const last  = this.to;
 7    return {
 8      next() {
 9        return current <= last
10          ? { value: current++, done: false }
11          : { value: undefined, done: true };
12      },
13    };
14  },
15};
16
17[...range];                     // [1, 2, 3, 4, 5]
18for (const n of range) {}       // works natively

A generator is the simplest way to implement the protocol — see the modern functions post.

Cloning, comparing, grouping

structuredClone (ES2022, native)

The recommended deep-clone for plain data. Handles Date, Map, Set, RegExp, typed arrays, and circular references. Does not clone functions, DOM nodes, or class instances (the prototype is lost).

1const original = { tags: ["a", "b"], created: new Date() };
2const copy     = structuredClone(original);

Object.assign and spread

Both copy own enumerable string-keyed properties. Spread is the more idiomatic form and is preferred for new code.

Object.groupBy and Map.groupBy (ES2024)

Group an iterable by the value a callback returns:

1const orders = [
2  { id: 1, status: "paid"    },
3  { id: 2, status: "pending" },
4  { id: 3, status: "paid"    },
5];
6
7Object.groupBy(orders, (o) => o.status);
8// { paid: [{id:1,...}, {id:3,...}], pending: [{id:2,...}] }

Map.groupBy is the same but uses a Map, so the keys can be any value (objects included).

Proxy and Reflect

A Proxy intercepts low-level operations on an object — get, set, delete, has, function call, construct. Use sparingly: every intercepted operation pays a runtime cost and confuses tools.

 1const audited = new Proxy({}, {
 2  set(target, key, value) {
 3    console.log(`set ${key} = ${value}`);
 4    return Reflect.set(target, key, value);
 5  },
 6  get(target, key) {
 7    console.log(`get ${key}`);
 8    return Reflect.get(target, key);
 9  },
10});
11
12audited.name = "Ali";   // logs: set name = Ali
13audited.name;           // logs: get name

Reflect mirrors every trap-able operation — it is the recommended way to forward inside a handler.

Real-world uses include validation layers, deep-reactivity systems (Vue, MobX, Solid), and access loggers. They are not a substitute for normal getters/setters in everyday code.

Two patterns that show up everywhere

1. Configuration “with defaults”

1function configure(options) {
2  return Object.freeze({
3    method:  "GET",
4    timeout: 30_000,
5    retries: 3,
6    ...options,                // user wins
7  });
8}

2. Safe deep merge of plain JSON

1function mergeJSON(a, b) {
2  if (typeof a !== "object" || a === null) return b;
3  if (typeof b !== "object" || b === null) return b;
4  const out = Array.isArray(a) ? [...a] : { ...a };
5  for (const key of Object.keys(b)) {
6    out[key] = mergeJSON(a[key], b[key]);
7  }
8  return out;
9}

For anything more complex, reach for a battle-tested library — lodash.merge or deepmerge — rather than reinventing the wheel.

Summary

  • Every property is described by value/writable/enumerable/configurable (or get/set). Object.defineProperty gives you full control.
  • freeze, seal, preventExtensions are shallow. Recurse for deep immutability.
  • Symbols give you collision-free hidden keys and the hooks (Symbol.iterator, Symbol.toPrimitive, …) to integrate with language built-ins.
  • The iterable protocol makes any object work with for...of, spread, destructuring, and Promise.all.
  • structuredClone is the modern deep clone; Object.groupBy / Map.groupBy (ES2024) collapse the classic “group an array” task into one call.
  • Proxy + Reflect enable powerful meta-programming — use sparingly.

That completes the four-post object series. Next in the JavaScript track: Functional programming — the discipline of building programs as compositions of pure functions.