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() {
3    return this.name.toUpperCase()
4  },
5  enumerable: true,
6  configurable: true
7})

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) {
 3    this.amount = amount
 4    this.currency = currency
 5  }
 6  [Symbol.toPrimitive](hint) {
 7    if (hint === 'number') return this.amount
 8    if (hint === 'string') return `${this.currency} ${this.amount}`
 9    return `${this.amount}`
10  }
11}
12
13const m = new Money(50, 'USD')
14;+m // 50
15;`${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) {
19} // 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  {},
 3  {
 4    set(target, key, value) {
 5      console.log(`set ${key} = ${value}`)
 6      return Reflect.set(target, key, value)
 7    },
 8    get(target, key) {
 9      console.log(`get ${key}`)
10      return Reflect.get(target, key)
11    }
12  }
13)
14
15audited.name = 'Ali' // logs: set name = Ali
16audited.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.