JavaScript Part 2

Errors, Proxy, Reflect, Copying and Immutability

Throwing and catching, Proxy traps with Reflect, shallow versus deep copies, structuredClone and immutable updates.

Lesson 0630 min read#errors#proxy#immutability

On this page

  1. Words used in this lesson
  2. Part 1. Errors
  3. Part 2. Proxy and Reflect
  4. Part 3. Copying, shallow and deep
  5. Part 4. Spread pitfalls
  6. Part 5. Object.freeze and its depth
  7. Part 6. Immutable updates
  8. Try it
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: errors are objects you throw and catch, a Proxy lets you intercept what happens to an object, and copying data correctly (and not changing it by accident) is mostly about knowing which copies are shallow and which are deep.

Three topics that look separate are linked by one idea: control what happens to your data. Errors decide what happens when code fails. A Proxy decides what happens when someone reads or writes an object. Copying and immutability decide what happens when two parts of the program share the same object.

You should know objects and property descriptors from Lesson 0, classes and private fields from Lesson 1, and promises from Lesson 2. Iterating over what a Proxy exposes uses the protocol in Lesson 4. Memory effects of copying are in Lesson 7.

What I ran / checked. Every code block was run with Node v22.22.0 on Linux; output is pasted from real runs. Error message text is V8's wording and differs in other engines. The timings (a Proxy read, three ways to clone) come from one run on this machine and only show the rough size of the difference. I did not run any of this in a browser, so statements about browsers (for example that structuredClone rejects DOM nodes) are marked as such.

Words used in this lesson

  • Exception is a thrown value that stops normal flow until something catches it. In JavaScript you can throw any value, but you should throw Error objects.
  • Stack trace is the list of function calls active when an error was created. It is in error.stack.
  • Proxy is an object that wraps another object (the target) and runs your handler functions, called traps, when someone reads, writes, deletes or calls it.
  • Reflect is a built-in object with one function for each basic operation (Reflect.get, Reflect.set, ...). A trap usually calls the matching Reflect function to do the normal thing.
  • Shallow copy copies the top level only. Nested objects are still shared with the original. Deep copy copies every level.
  • Immutable means "not changed after creation". You make a changed copy instead.
  • Structured clone is the browser and Node algorithm behind structuredClone. It copies many built-in types and handles cycles.

Part 1. Errors

try, catch, finally

JavaScript
function risky(n) {  try {    console.log('  try: start', n);    if (n === 1) throw new Error('bad input');    if (n === 2) return 'from try';    return 'end of try';  } catch (e) {    console.log('  catch:', e.message);    return 'from catch';  } finally {    console.log('  finally runs');  }}for (const n of [0, 1, 2]) console.log(n, '->', risky(n));
TEXT
  try: start 0  finally runs0 -> end of try  try: start 1  catch: bad input  finally runs1 -> from catch  try: start 2  finally runs2 -> from try

finally ran in all three cases, even when try had already executed return. The return value is saved, finally runs, then the function returns. That makes finally the right place to release things (close a file, hide a spinner).

A return inside finally replaces everything, including a thrown error. I ran function f() { try { throw new Error('x'); } finally { return 'finally swallowed the error'; } } and it returned that string with no error. Do not return from finally.

You can leave out the variable when you do not need it: catch { ... } is valid. I ran it.

Built-in error types

TypeWhen V8 throws itMessage I got
TypeErrorwrong type of valueCannot read properties of null (reading 'foo')
ReferenceErrorname does not existundefinedVar is not defined
RangeErrornumber out of range, too deep recursionInvalid array length, Maximum call stack size exceeded
SyntaxErrorbad code or bad JSON.parse inputExpected property name or '}' in JSON at position 1 (line 1 column 2)
URIErrorbad URI escapeURI malformed
EvalErrorexists, but modern engines rarely throw itnot shown

All of them inherit from Error, so e instanceof Error is true, and e.name tells them apart. The message text is not part of the standard. Never match on it in code.

An error is an object with a few own properties. Object.keys(new Error('hi')) is [] (they are not enumerable), and Object.getOwnPropertyNames gives [ 'stack', 'message' ]. That explains a classic trap: JSON.stringify(new Error('x')) returns {}. I printed it, and a spread copy { ...new Error('x') } is also {}. To log an error as JSON, pick the fields yourself (name, message, stack, cause).

Error cause

When you catch a low-level error and throw a higher-level one, keep the original. The second argument of the Error constructor takes { cause }:

JavaScript
class AppError extends Error {  constructor(message, options) { super(message, options); this.name = this.constructor.name; }}class NotFoundError extends AppError {  constructor(what, options) { super(`${what} not found`, options); this.status = 404; this.what = what; }}function load(id) {  try { JSON.parse('{oops'); }  catch (err) { throw new NotFoundError('user ' + id, { cause: err }); }}try { load(7); } catch (e) {  console.log(e.name, e.message, e.status, e instanceof AppError, e instanceof NotFoundError, e instanceof Error);  console.log('cause:', e.cause.name, '-', e.cause.message);  console.log(String(e), '|', e.stack.split('\n')[0]);}
TEXT
NotFoundError user 7 not found 404 true true truecause: SyntaxError - Expected property name or '}' in JSON at position 1 (line 1 column 2)NotFoundError: user 7 not found | NotFoundError: user 7 not found

Why this design:

  • A base class (AppError) lets you catch all your own errors with one instanceof. Subclasses carry extra data (status, what).
  • this.name = this.constructor.name makes the printed name match the class. Without it the name would be Error.
  • cause keeps the chain, so the log shows both the friendly error and the real reason. Pass options through to super. If your constructor calls super(m) only, the cause is lost. I tested that: new Bad('m', { cause: 'c' }).cause printed undefined.
  • instanceof works for subclasses of Error in modern JavaScript. For class syntax, see Lesson 1.
  • For several errors at once there is AggregateError. I ran new AggregateError([new Error('a'), new TypeError('b')], 'many failed') and .errors held both. Promise.any rejects with one.

Errors and async code

The try/catch only sees errors thrown while the try block is running. A promise error comes later, so you need await (or .catch). The full promise story is in Lesson 2. Here is the recap I ran:

JavaScript
const wait = (ms) => new Promise(r => setTimeout(r, ms));async function fail() { await wait(5); throw new Error('async boom'); }(async () => {  try { await fail(); } catch (e) { console.log('caught with await:', e.message); }  try { fail(); } catch (e) { console.log('never printed'); }  await wait(30);})();process.on('unhandledRejection', (reason) => { console.log('unhandledRejection event:', reason.message); });fail().catch(e => console.log('.catch got:', e.message));
TEXT
caught with await: async boom.catch got: async boomunhandledRejection event: async boom
  • await fail() inside try is caught.
  • fail() without await returns a promise at once, so the try ends with no error. The rejection happens later, and nobody handles it. That is the unhandledRejection line. never printed did not print.
  • In Node 22 an unhandled rejection with no listener ends the process. I ran async function fail() { throw new Error('nobody handles me'); } fail(); and Node printed the stack and exited with code 1. With --unhandled-rejections=warn it printed a warning and exited with code 0. In browsers it is only a console error.
  • Promise.allSettled never rejects. It gives { status, value | reason } for each, and Promise.any rejects with an AggregateError when all fail:
TEXT
fulfilled:1 rejected:no fulfilled:3AggregateError 2

An async function that throws gives a rejected promise; it does not throw to the caller. This is why forgetting await is a bug that looks like "my error handler never runs".

Throwing things that are not errors

JavaScript lets you throw 'text', throw 42 or throw { code: 1 }. Do not. Look at what you lose:

JavaScript
try { throw 'a string'; } catch (e) { console.log(typeof e, e instanceof Error, e.stack); }try { throw { code: 42 }; } catch (e) { console.log(typeof e, e.code, e.stack); }Promise.reject('just a string').catch(e => console.log('rejected with', typeof e));
TEXT
string false undefinedobject 42 undefinedrejected with string

No stack, no instanceof Error. A top-level throw 'a string' crashed my script with exit code 1 and no stack to follow. The value in catch (e) can be anything, because libraries and old code do throw strings. (TypeScript with strict types it as unknown for this reason.) So normalise at the edge:

JavaScript
const toError = (x) => x instanceof Error ? x : new Error(typeof x === 'string' ? x : JSON.stringify(x), { cause: x });console.log(toError('plain').message, toError({ code: 42 }).message, toError({ code: 42 }).cause);
TEXT
plain {"code":42} { code: 42 }

Error.isError does not exist in this Node (I checked), and instanceof Error fails for errors from another iframe or realm. In a plain Node app instanceof is fine.

Practical rules

  1. Throw Error (or a subclass). Add data as fields. Keep the cause.
  2. Catch only where you can do something useful: retry, show a message, translate the error. Otherwise let it bubble.
  3. Never leave an empty catch {}. That hides bugs.
  4. Always await or return promises you care about. Use finally for clean-up.
  5. At the top, add one last-resort handler (unhandledRejection in Node, window.onunhandledrejection in a browser) that logs and reports, not one that hides.

Part 2. Proxy and Reflect

A Proxy wraps a target object. Every basic operation on the proxy (get, set, delete, in, Object.keys, call) goes through a trap if you defined one. If you did not define a trap, the operation goes straight to the target.

TEXT
code  ->  proxy  ->  [your trap?]  ->  target

Reflect has the default behaviour for each operation as a function: Reflect.get(target, key, receiver), Reflect.set(...), Reflect.has(...), Reflect.ownKeys(...), Reflect.deleteProperty(...), Reflect.apply(...). Inside a trap, calling the matching Reflect method keeps the normal behaviour, and you add your logic around it.

Example 1: validation

JavaScript
const user = { name: 'Asha', age: 30 };const validator = {  set(target, prop, value, receiver) {    if (prop === 'age') {      if (!Number.isInteger(value)) throw new TypeError('age must be an integer');      if (value < 0) throw new RangeError('age must be >= 0');    }    return Reflect.set(target, prop, value, receiver);  },  deleteProperty(t, p) { console.log('  blocked delete of', p); return false; },};const safe = new Proxy(user, validator);safe.age = 31; console.log(user.age);try { safe.age = 'old'; } catch (e) { console.log(e.name + ': ' + e.message); }try { safe.age = -1; } catch (e) { console.log(e.name + ': ' + e.message); }console.log(delete safe.name, user.name);(function () { 'use strict'; try { delete safe.name; } catch (e) { console.log('strict mode:', e.name + ': ' + e.message); } })();
TEXT
31TypeError: age must be an integerRangeError: age must be >= 0  blocked delete of namefalse Asha  blocked delete of namestrict mode: TypeError: 'deleteProperty' on proxy: trap returned falsish for property 'name'

Trace:

  1. safe.age = 31 calls the set trap. The checks pass, so Reflect.set writes to user. user.age is 31: the proxy wrote through to the target.
  2. 'old' fails Number.isInteger, so the trap throws before anything is written.
  3. The deleteProperty trap returns false, which means "refused". In sloppy mode the delete expression just gives false. In strict mode (and in modules and classes) it throws a TypeError.
  4. A set trap must return a truthy value for success. In strict mode a falsy return throws. Returning the result of Reflect.set does this correctly.

Note that validation lives in the proxy, not in user. Code that holds the raw user can still write anything. Hand out only the proxy.

Example 2: logging reads, deeply

JavaScript
function logged(obj, path = '') {  return new Proxy(obj, {    get(t, p, r) {      const v = Reflect.get(t, p, r);      if (typeof p === 'symbol') return v;      console.log(`  get ${path}${p}`);      return v !== null && typeof v === 'object' ? logged(v, `${path}${p}.`) : v;    },    has(t, p) { console.log('  has', String(p)); return Reflect.has(t, p); },    ownKeys(t) { console.log('  ownKeys'); return Reflect.ownKeys(t); },  });}const cfg = logged({ db: { host: 'h', port: 5432 }, debug: false });console.log(cfg.db.port);console.log('debug' in cfg);console.log(Object.keys(cfg));
TEXT
  get db  get db.port5432  has debugtrue  ownKeys[ 'db', 'debug' ]
  • cfg.db.port triggered two traps: reading db (which returned another proxy, wrapping the nested object) and then port. Wrapping nested objects on demand is how libraries make "deep" reactive objects.
  • in calls the has trap. Object.keys calls ownKeys.
  • The typeof p === 'symbol' line skips symbol keys. Without it, internal lookups such as Symbol.toPrimitive or Symbol.iterator (Lesson 4) would be logged and wrapped too.

Other traps: apply (call a function), construct (new), getPrototypeOf, defineProperty and getOwnPropertyDescriptor. I ran apply on a function proxy: calling fnProxy(1, 2) logged apply [ 1, 2 ] and, with a trap that multiplied the result by 10, returned 30.

A Proxy can also give a default for a missing key: a get trap that returns the text no x when p in t is false printed no x for .x.

Reflect is more than a helper

JavaScript
console.log(Reflect.has({ a: 1 }, 'a'), Reflect.ownKeys({ a: 1, [Symbol('s')]: 2 }), Reflect.getPrototypeOf([]) === Array.prototype);const frozen = Object.freeze({ a: 1 });console.log('Reflect.set on frozen ->', Reflect.set(frozen, 'a', 2), '| Reflect.defineProperty ->', Reflect.defineProperty(frozen, 'b', { value: 1 }));try { Object.defineProperty(frozen, 'b', { value: 1 }); } catch (e) { console.log('Object.defineProperty throws:', e.message); }
TEXT
true [ 'a', Symbol(s) ] trueReflect.set on frozen -> false | Reflect.defineProperty -> falseObject.defineProperty throws: Cannot define property b, object is not extensible

Two good reasons to use Reflect inside traps:

  • It returns true or false instead of throwing, which is exactly what a trap must return. Object.defineProperty throws on failure.
  • It takes a receiver, which matters for getters and inheritance. A getter that uses this should see the proxy, not the raw target. Passing receiver along keeps that right.
  • Reflect.ownKeys includes symbols and non-enumerable keys, unlike Object.keys.

Limits and traps of Proxy

Private fields break. A private #n field belongs to the real object, not to the proxy:

JavaScript
class Counter { #n = 0; inc() { return ++this.#n; } }const c = new Counter(); const pc = new Proxy(c, {});try { pc.inc(); } catch (e) { console.log(e.name + ': ' + e.message); }const pc2 = new Proxy(c, { get(t, p) { const v = Reflect.get(t, p, t); return typeof v === 'function' ? v.bind(t) : v; } });console.log(pc2.inc());
TEXT
TypeError: Cannot read private member #n from an object whose class did not declare it1

Inside inc, this is the proxy, and the proxy has no #n. The fix is to bind methods to the target in the get trap (pc2 worked). Built-in collections like Map and Set have the same issue, so wrapping them needs the same bind.

Invariants. A proxy cannot lie about everything. If the target has a read-only, non-configurable property, the proxy must return its real value. I made a proxy that returned 2 for a property fixed at 1:

TEXT
TypeError: 'get' on proxy: property 'k' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected '1' but got '2')

Speed. A proxied read goes through a function. On one million reads of obj.v, my machine took about 5.5 ms for the plain object and about 31.6 ms through a proxy with a pass-through get trap (single run, local numbers, only the ratio is useful). That is fine for validation and tooling, but do not put a proxy in a hot loop.

Revoking. Proxy.revocable(target, handler) gives { proxy, revoke }. After revoke(), every use throws TypeError: Cannot perform 'get' on a proxy that has been revoked. This is useful for handing temporary access to some code.

Real uses: reactivity systems (Vue 3 tracks reads and writes with proxies), validation, logging and debugging, API clients that turn api.users.get() into a URL, and test mocks. Prefer a plain function, a class or a getter when one is enough.

Part 3. Copying, shallow and deep

The shallow copy trap

Spread ({ ...obj }), Object.assign, Array.from, arr.slice() and [...arr] all copy one level. Nested objects and arrays are shared:

JavaScript
const orig = { name: 'Asha', tags: ['a', 'b'], address: { city: 'Pune' } };const copy = { ...orig };copy.name = 'Ben'; copy.tags.push('c'); copy.address.city = 'Delhi';console.log(orig);console.log(copy.tags === orig.tags, copy.address === orig.address);
TEXT
{ name: 'Asha', tags: [ 'a', 'b', 'c' ], address: { city: 'Delhi' } }true true

name is a string (a primitive), so the copy has its own. But tags and address are references, so both objects point at the same array and the same object. Changing the copy changed the original. The same is true for an array of objects: [...list] gives a new array but the same objects. I ran slice, Array.from and concat on an array of objects and each kept list[1] === copy[1].

JSON round trip: the old trick and why it fails

JSON.parse(JSON.stringify(x)) makes a deep copy, but it only knows JSON types:

JavaScript
const viaJSON = JSON.parse(JSON.stringify({ d: new Date(0), u: undefined, f() {}, n: NaN, i: Infinity, m: new Map([[1, 2]]), s: new Set([1]), r: /x/g, e: new Error('e') }));console.log(viaJSON);try { JSON.stringify({ b: 1n }); } catch (e) { console.log(e.name + ': ' + e.message); }const circ = { a: 1 }; circ.self = circ;try { JSON.stringify(circ); } catch (e) { console.log(e.name + ': ' + e.message.split('\n')[0]); }
TEXT
{  d: '1970-01-01T00:00:00.000Z',  n: null,  i: null,  m: {},  s: {},  r: {},  e: {}}TypeError: Do not know how to serialize a BigIntTypeError: Converting circular structure to JSON
  • The Date became a string. undefined and the function were dropped. NaN and Infinity became null. Map, Set, RegExp and Error became {}.
  • A BigInt or a circular reference throws.

structuredClone

structuredClone(value) is built in (Node 17 and later, and current browsers). It copies deeply and keeps many built-in types:

JavaScript
const orig = { name: 'Asha', tags: ['a'], address: { city: 'Pune' }, when: new Date(0), m: new Map([[1, { x: 1 }]]), s: new Set([1]), re: /x/gi, n: undefined, big: 10n };const c = structuredClone(orig);c.tags.push('z'); c.address.city = 'Delhi'; c.m.get(1).x = 99;console.log(orig.tags, orig.address, orig.m.get(1));console.log(c);const circ = { a: 1 }; circ.self = circ;const cc = structuredClone(circ); console.log(cc.self === cc, cc !== circ);
TEXT
[ 'a' ] { city: 'Pune' } { x: 1 }{  name: 'Asha',  tags: [ 'a', 'z' ],  address: { city: 'Delhi' },  when: 1970-01-01T00:00:00.000Z,  m: Map(1) { 1 => { x: 99 } },  s: Set(1) { 1 },  re: /x/gi,  n: undefined,  big: 10n}true true

The original is untouched. Date, Map, Set, RegExp, undefined and BigInt survived, and the cycle was kept (cc.self === cc). structuredClone also keeps shared references inside the data the same way: two keys pointing at one object still point at one object in the copy.

structuredClone limits

JavaScript
const tests = {  function: () => structuredClone({ f() {} }),  symbolValue: () => structuredClone({ s: Symbol('x') }),  weakmap: () => structuredClone(new WeakMap()),};for (const [k, f] of Object.entries(tests)) { try { f(); console.log(k, 'ok'); } catch (e) { console.log(k, '->', e.name + ': ' + e.message); } }class Point { constructor(x) { this.x = x; } get double() { return this.x * 2; } }const p = structuredClone(new Point(3));console.log(p, p instanceof Point, p.constructor === Object, p.double);console.log(Object.getOwnPropertyDescriptor(structuredClone({ get g() { return 5; } }), 'g'));console.log(structuredClone(Object.assign(Object.create({ inherited: 1 }), { own: 2 })));
TEXT
function -> DataCloneError: f() {} could not be cloned.symbolValue -> DataCloneError: Symbol(x) could not be cloned.weakmap -> DataCloneError: #<WeakMap> could not be cloned.{ x: 3 } false true undefined{ value: 5, writable: true, enumerable: true, configurable: true }{ own: 2 }
ThingWhat structuredClone does
function, symbol value, WeakMap, WeakSetthrows DataCloneError
class instancebecomes a plain object: the prototype is gone, so methods and getters are missing (p instanceof Point is false, p.double is undefined)
getterread once, stored as a plain value (value: 5)
inherited propertiesdropped ({ own: 2 })
DOM nodethrows DataCloneError in a browser (from the specification; I did not run it in a browser)
Errorkept, with message and cause (I ran a RangeError with a cause: it was still a RangeError, bad, c)
ArrayBuffercopied, or moved with { transfer: [buf] }

The transfer option moves the memory instead of copying it. After structuredClone(buf, { transfer: [buf] }), the original buf.byteLength was 0 and buf.detached was true, while the new buffer had length 8. This is how large binary data goes to a Worker without a copy (more on ArrayBuffer in Lesson 7).

If you need a copy with methods, write a clone() method on the class, or rebuild the object (new Point(p.x)).

Cost

For 100,000 small objects (each with an array and a nested object), I timed three ways to clone on this machine, one run each: structuredClone took about 510 ms, the JSON round trip about 100 ms, and a hand-written spread ({ ...o, tags: [...o.tags], meta: { ...o.meta } }) about 29 ms. Do not read this as "structuredClone is slow". It is the general tool, and a copy written for your exact shape is faster. The point: copying big data is not free, so copy only what you must.

Part 4. Spread pitfalls

JavaScript
const a = { x: 1, nested: { y: 2 } };console.log({ ...a, nested: { ...a.nested, y: 3 } }, a.nested.y);console.log({ ...{ a: 1, b: 2 }, b: undefined });console.log({ ...'hi' }, { ...[10, 20] }, { ...null }, { ...5 });const getterSrc = { get now() { console.log('  getter ran'); return 42; } };const sp = { ...getterSrc }; console.log(Object.getOwnPropertyDescriptor(sp, 'now'));class P { constructor() { this.own = 1; } method() {} }const sp2 = { ...new P() }; console.log(sp2, sp2 instanceof P, typeof sp2.method);console.log(Object.getOwnPropertyDescriptor({ ...Object.freeze({ a: 1 }) }, 'a'));const hidden = Object.defineProperty({ v: 1 }, 'h', { value: 2, enumerable: false }); console.log({ ...hidden });
TEXT
{ x: 1, nested: { y: 3 } } 2{ a: 1, b: undefined }{ '0': 'h', '1': 'i' } { '0': 10, '1': 20 } {} {}  getter ran{ value: 42, writable: true, enumerable: true, configurable: true }{ own: 1 } false undefined{ value: 1, writable: true, enumerable: true, configurable: true }{ v: 1 }
PitfallWhat happened
Shallowthe nested y needed its own spread to change without touching a
Later key wins, even undefined{ ...x, b: undefined } keeps the key b with value undefined. It did not fall back to 2
Odd sourcesstrings and arrays spread into index keys; null and numbers give nothing
Gettersthe getter ran and the copy holds the plain value 42, not a getter
Classesown properties only: not an instance of P, and no methods
Frozen sourcethe copy is not frozen: spread gives normal, writable properties
Non-enumerableskipped

Part 5. Object.freeze and its depth

Object.freeze stops changes to an object, but only its own top level (Lesson 0 covers freeze against seal). In strict mode, failed writes throw. I ran this with 'use strict':

JavaScript
'use strict';const cfg = Object.freeze({ port: 80, db: { host: 'h' }, list: [1, 2] });try { cfg.port = 81; } catch (e) { console.log(e.name + ': ' + e.message); }try { cfg.extra = 1; } catch (e) { console.log(e.name + ': ' + e.message); }cfg.db.host = 'changed'; cfg.list.push(3);console.log(cfg, Object.isFrozen(cfg), Object.isFrozen(cfg.db));
TEXT
TypeError: Cannot assign to read only property 'port' of object '#<Object>'TypeError: Cannot add property extra, object is not extensible{ port: 80, db: { host: 'changed' }, list: [ 1, 2, 3 ] } true false

The top level is locked. But cfg.db.host and cfg.list changed freely, because freezing is shallow too. A recursive deepFreeze fixes it:

JavaScript
function deepFreeze(o) { Object.freeze(o); for (const v of Object.values(o)) if (v && typeof v === 'object' && !Object.isFrozen(v)) deepFreeze(v); return o; }const d = deepFreeze({ port: 80, db: { host: 'h' }, list: [1, 2] });try { d.db.host = 'x'; } catch (e) { console.log(e.name + ': ' + e.message); }try { d.list.push(3); } catch (e) { console.log(e.name + ': ' + e.message); }
TEXT
TypeError: Cannot assign to read only property 'host' of object '#<Object>'TypeError: Cannot add property 2, object is not extensible

More limits I tested:

  • Sloppy mode fails silently. The same write on a frozen object in a non-strict function returned the old value 1 with no error. Modules and classes are strict, so you will see errors there.
  • A frozen Map still changes. Object.freeze(new Map([[1, 2]])) then m.set(3, 4) gave a size of 2. Freezing guards properties, not internal slots.
  • Frozen arrays reject mutating methods: fa.sort() threw TypeError: Cannot assign to read only property '0' of object '[object Array]'.
  • Freezing is a runtime guard, and freezing big objects on each update has a cost. In TypeScript, readonly and as const check the same thing at compile time for free.

Part 6. Immutable updates

The idea: never change the old value. Build a new one, and reuse the parts that did not change. Frameworks like React rely on this, because "is it the same object?" is a cheap way to ask "did it change?".

JavaScript
const state = { user: { name: 'Asha', prefs: { theme: 'dark', lang: 'en' } }, todos: [{ id: 1, done: false }, { id: 2, done: false }], count: 0 };const next = {  ...state,  user: { ...state.user, prefs: { ...state.user.prefs, theme: 'light' } },  todos: state.todos.map(t => t.id === 2 ? { ...t, done: true } : t),};console.log(JSON.stringify(state.user.prefs), JSON.stringify(next.user.prefs));console.log('unchanged parts shared:', next.todos[0] === state.todos[0], next.user.prefs.lang === state.user.prefs.lang, '| changed:', next.todos[1] === state.todos[1], next.user === state.user);
TEXT
{"theme":"dark","lang":"en"} {"theme":"light","lang":"en"}unchanged parts shared: true true | changed: false false

You copy every object on the path to the change (user, prefs, the one todo) and share the rest. Todo 1 is the same object in both states. This is called structural sharing. Libraries such as Immer let you write "mutating" code on a draft and produce this for you.

Array patterns

GoalNon-mutating way
add at end / start[...arr, x] / [x, ...arr]
remove by valuearr.filter(v => v !== 2)
replace onearr.map(v => v === 3 ? 30 : v)
insert in the middle[...arr.slice(0, i), x, ...arr.slice(i)]
remove a key from an objectconst { name, ...rest } = obj
JavaScript
const arr = [1, 2, 3, 4];console.log('add', [...arr, 5], '| remove', arr.filter(x => x !== 2), '| replace', arr.map(x => x === 3 ? 30 : x), '| insert', [...arr.slice(0, 2), 99, ...arr.slice(2)]);console.log(arr);
TEXT
add [ 1, 2, 3, 4, 5 ] | remove [ 1, 3, 4 ] | replace [ 1, 2, 30, 4 ] | insert [ 1, 2, 99, 3, 4 ][ 1, 2, 3, 4 ]

The newer copying array methods

Node 22 has four array methods that return a new array and leave the original alone: toSorted, toReversed, toSpliced and with. Compare them with the old ones (sort, reverse, splice) that change the array in place:

JavaScript
const nums = [3, 1, 2];const sorted = nums.toSorted((a, b) => a - b), rev = nums.toReversed(), sp = nums.toSpliced(1, 1, 'a', 'b'), w = nums.with(0, 'first');console.log(nums, sorted, rev, sp, w);const r = nums.sort(); console.log('sort mutates and returns same array:', r === nums, nums);try { nums.with(10, 'x'); } catch (e) { console.log(e.name + ': ' + e.message); }console.log([1, 2, 3].with(-1, 'last'), [1, 2, 3].toSpliced(1));console.log([5,1,10].sort(), [5,1,10].toSorted(), [5,1,10].toSorted((a,b)=>a-b));const sparse = [1, , 3]; console.log(sparse.toSorted(), sparse.sort());
TEXT
[ 3, 1, 2 ] [ 1, 2, 3 ] [ 2, 1, 3 ] [ 3, 'a', 'b', 2 ] [ 'first', 1, 2 ]sort mutates and returns same array: true [ 1, 2, 3 ]RangeError: Invalid index : 10[ 1, 2, 'last' ] [ 1 ][ 1, 10, 5 ] [ 1, 10, 5 ] [ 1, 5, 10 ][ 1, 3, undefined ] [ 1, 3, <1 empty item> ]
  • After the four copying calls, nums was still [3, 1, 2]. The old sort() changed it and returned the same array (r === nums).
  • with(index, value) is the non-mutating arr[index] = value. A negative index counts from the end, and an out-of-range index throws RangeError. The spread alternative would just append.
  • toSpliced(1) with only a start removed everything from index 1. The signature is the same as splice.
  • Without a compare function, toSorted sorts as text, just like sort: [5,1,10] became [1, 10, 5]. Always pass (a, b) => a - b for numbers.
  • On a sparse array the copying methods turn holes into undefined, but sort keeps the hole.
  • These methods copy the array shallowly. The elements are the same objects, so toSorted on an array of objects still shares those objects.

Try it

  1. const a = { n: { v: 1 } }; const b = { ...a }; b.n.v = 2; What is a.n.v? Fix it so a stays unchanged, without structuredClone.
  2. Write class ValidationError with a field property and a cause. Show how to print the whole chain of causes.
  3. Create a Proxy that makes reading a missing property throw ReferenceError('no such field: x'). What do you need to be careful about?
  4. Why does structuredClone(new Date()) work but structuredClone(() => 1) throws?
  5. Remove the item with id: 2 from state.todos without mutating. Then do it with the new method toSpliced.
  6. You froze const cfg = Object.freeze({ list: [] }). A teammate calls cfg.list.push(1) and it works. Why?

Answers

  1. a.n.v is 2, because the spread is shallow and n is shared. Fix: const b = { ...a, n: { ...a.n, v: 2 } }.
  2. class ValidationError extends Error { constructor(field, msg, options) { super(msg, options); this.name = 'ValidationError'; this.field = field; } } then for (let e = err; e; e = e.cause) console.log(e.name, e.message);. The loop ends when cause is undefined.
  3. new Proxy(obj, { get(t, p, r) { if (typeof p === 'symbol' || p in t) return Reflect.get(t, p, r); throw new ReferenceError('no such field: ' + String(p)); } }). I ran it: p.x threw as wanted, and console.log(p) still worked because Node's printing only looks up symbols and own keys. But JSON.stringify(p) threw no such field: toJSON and await p threw no such field: then, because those operations look up toJSON and then first. Allow those names, or the proxy breaks tools that probe objects.
  4. structuredClone knows built-in data types such as Date, Map and RegExp, but a function is code with a closure and cannot be copied this way. It throws DataCloneError, as I ran.
  5. state.todos.filter(t => t.id !== 2). With toSpliced: const i = state.todos.findIndex(t => t.id === 2); state.todos.toSpliced(i, 1). Check that i !== -1 first, because toSpliced(-1, 1) would remove the last item.
  6. Object.freeze is shallow. The array cfg.list is a separate object that was not frozen. Use deepFreeze, or Object.freeze the list too.

Interview questions

What does finally do, and what happens if you return inside it? (Must know)

finally runs after try and catch in every case, including when try has already returned or when an error is thrown. I ran three cases and it printed each time. A return inside finally replaces the earlier return value and even a thrown error: my test function returned a string and the error vanished. So I use finally only to release resources, and I never return from it.

Why should you throw Error objects instead of strings? (Must know)

An Error has a stack, a name, a message and a cause, and works with instanceof. In my test throw 'a string' gave e.stack of undefined and e instanceof Error of false, and an uncaught one crashed Node with no stack to read. Because other code may still throw non-errors, I normalise in a catch block with a small toError helper.

What is error.cause and how do you build a custom error class correctly? (Senior signal)

cause keeps the original error when I wrap it in a higher-level one: new Error('msg', { cause: err }). For a custom class I extend Error, call super(message, options) so the cause is passed on, and set this.name. I tested the trap: a constructor that calls only super(m) loses the cause (undefined). I also add a base class so callers can use one instanceof AppError, and fields like status.

Why is JSON.stringify(error) empty? (Trap)

message and stack are own properties but not enumerable, and JSON.stringify and spread only use enumerable ones. I printed {} for both. To log an error I build the object myself: { name, message, stack, cause }.

Why does try { fetchData(); } catch not catch an async error? (Must know)

An async function returns a promise. Its error becomes a rejection later, after the try block has finished. I need await inside the try, or .catch. In my test the version without await printed nothing in catch and ended up as an unhandledRejection. In Node 22 that ends the process with exit code 1 by default.

What is a Proxy, and what is Reflect for? (Must know)

A Proxy wraps a target and runs trap functions for operations like get, set, delete, in, Object.keys and calls. Reflect has the default version of each operation. In a trap I call the matching Reflect method to do the normal thing, and I add my rule around it. Reflect methods return true or false where the older Object methods throw, and they take a receiver so getters see the right this.

Show a use of Proxy, and its limits. (Senior signal)

Validation: a set trap checks that age is an integer and throws before writing. Logging: a get trap that wraps nested objects and logs paths like db.port. The limits I hit: private #fields fail through a proxy (Cannot read private member) until I bind methods to the target; invariants stop a proxy from lying about read-only properties; and reads were about six times slower in my one-run test (5.5 ms against 31.6 ms for a million reads on my machine). I would not use one in a hot loop.

What is the difference between shallow and deep copy? Which operations are shallow? (Must know)

A shallow copy duplicates only the top level; nested objects are shared. Spread, Object.assign, slice, concat and Array.from are all shallow. In my test copy.address.city = 'Delhi' changed the original, and copy.tags === orig.tags was true. A deep copy duplicates all levels, with structuredClone, or a hand-written copy that spreads every level on the path.

What does structuredClone support, and what doesn't it? (Senior signal)

It handles Date, Map, Set, RegExp, BigInt, undefined, typed arrays, errors and circular references. It throws DataCloneError for functions, symbols and WeakMap. It turns class instances into plain objects, so methods, getters and the prototype are lost (I got instanceof Point of false). It can transfer an ArrayBuffer instead of copying it. The JSON round trip is worse: dates become strings, undefined and functions vanish, and Map becomes {}.

Does Object.freeze make an object immutable? (Trap)

Only at the top level. In my test I froze a config, and cfg.db.host = 'changed' and cfg.list.push(3) both worked. I need a recursive deepFreeze for nested data. In sloppy mode, writes to a frozen object fail silently (I saw the old value returned), and a frozen Map can still be changed with set. In TypeScript I prefer readonly types plus immutable update code.

How do you update nested state without mutating it? (Must know)

I copy each object on the path to the change and reuse the rest: { ...state, user: { ...state.user, prefs: { ...state.user.prefs, theme: 'light' } } }. For arrays I use map to replace, filter to remove, and spread to add. In my run, the untouched todo was the same object in old and new state (true) and the changed path was different. That makes "did it change?" a cheap reference check. For deep updates I use Immer.

What are toSorted, toSpliced, with and toReversed, and why were they added? (Senior signal)

They are copying versions of sort, splice, arr[i] = x and reverse. They return a new array, so the original stays the same ([3, 1, 2] before and after in my test). sort() mutates and returns the same array. That makes them good for state updates. Gotchas: toSorted without a compare function still sorts as text ([5,1,10] gave [1, 10, 5]), and with throws a RangeError for an out-of-range index. They copy shallowly, and they need a recent runtime (Node 20 and later, current browsers), so I check the targets.

Key takeaways

  • finally always runs. Do not return from it. Throw Error objects, keep cause, and subclass for your own types.
  • try/catch only catches synchronous errors in the block. For promises use await inside the try, or .catch. An unhandled rejection ends a Node 22 process.
  • A Proxy runs traps for get, set, delete, in, keys and calls. Call the matching Reflect function inside. Private fields, invariants and speed are the limits.
  • Spread, assign, slice and concat are shallow. The JSON trick breaks dates, Map, undefined and cycles.
  • structuredClone is the deep copy tool. It keeps built-in types and cycles, but loses prototypes and refuses functions.
  • Object.freeze is shallow and silent in sloppy mode. Use deepFreeze or types.
  • Update immutably by copying the path and sharing the rest. Use toSorted, toSpliced, toReversed and with for arrays.

What's next

Lesson 7: Memory, Garbage Collection and WeakMap/WeakRef looks at what happens to all these objects after you are done with them, and how references you forget can keep them alive.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series