JavaScript Part 2
Errors, Proxy, Reflect, Copying and Immutability
Throwing and catching, Proxy traps with Reflect, shallow versus deep copies, structuredClone and immutable updates.
On this page
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
Errorobjects. - 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 matchingReflectfunction 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
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)); try: start 0 finally runs0 -> end of try try: start 1 catch: bad input finally runs1 -> from catch try: start 2 finally runs2 -> from tryfinally 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
| Type | When V8 throws it | Message I got |
|---|---|---|
TypeError | wrong type of value | Cannot read properties of null (reading 'foo') |
ReferenceError | name does not exist | undefinedVar is not defined |
RangeError | number out of range, too deep recursion | Invalid array length, Maximum call stack size exceeded |
SyntaxError | bad code or bad JSON.parse input | Expected property name or '}' in JSON at position 1 (line 1 column 2) |
URIError | bad URI escape | URI malformed |
EvalError | exists, but modern engines rarely throw it | not 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 }:
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]);}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 foundWhy this design:
- A base class (
AppError) lets you catch all your own errors with oneinstanceof. Subclasses carry extra data (status,what). this.name = this.constructor.namemakes the printed name match the class. Without it the name would beError.causekeeps the chain, so the log shows both the friendly error and the real reason. Passoptionsthrough tosuper. If your constructor callssuper(m)only, the cause is lost. I tested that:new Bad('m', { cause: 'c' }).causeprintedundefined.instanceofworks for subclasses ofErrorin modern JavaScript. For class syntax, see Lesson 1.- For several errors at once there is
AggregateError. I rannew AggregateError([new Error('a'), new TypeError('b')], 'many failed')and.errorsheld both.Promise.anyrejects 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:
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));caught with await: async boom.catch got: async boomunhandledRejection event: async boomawait fail()insidetryis caught.fail()withoutawaitreturns a promise at once, so thetryends with no error. The rejection happens later, and nobody handles it. That is theunhandledRejectionline.never printeddid 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 code1. With--unhandled-rejections=warnit printed a warning and exited with code0. In browsers it is only a console error. Promise.allSettlednever rejects. It gives{ status, value | reason }for each, andPromise.anyrejects with anAggregateErrorwhen all fail:
fulfilled:1 rejected:no fulfilled:3AggregateError 2An 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:
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));string false undefinedobject 42 undefinedrejected with stringNo 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:
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);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
- Throw
Error(or a subclass). Add data as fields. Keep the cause. - Catch only where you can do something useful: retry, show a message, translate the error. Otherwise let it bubble.
- Never leave an empty
catch {}. That hides bugs. - Always
awaitor return promises you care about. Usefinallyfor clean-up. - At the top, add one last-resort handler (
unhandledRejectionin Node,window.onunhandledrejectionin 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.
code -> proxy -> [your trap?] -> targetReflect 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
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); } })();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:
safe.age = 31calls thesettrap. The checks pass, soReflect.setwrites touser.user.ageis31: the proxy wrote through to the target.'old'failsNumber.isInteger, so the trap throws before anything is written.- The
deletePropertytrap returnsfalse, which means "refused". In sloppy mode thedeleteexpression just givesfalse. In strict mode (and in modules and classes) it throws aTypeError. - A
settrap must return a truthy value for success. In strict mode a falsy return throws. Returning the result ofReflect.setdoes 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
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)); get db get db.port5432 has debugtrue ownKeys[ 'db', 'debug' ]cfg.db.porttriggered two traps: readingdb(which returned another proxy, wrapping the nested object) and thenport. Wrapping nested objects on demand is how libraries make "deep" reactive objects.incalls thehastrap.Object.keyscallsownKeys.- The
typeof p === 'symbol'line skips symbol keys. Without it, internal lookups such asSymbol.toPrimitiveorSymbol.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
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); }true [ 'a', Symbol(s) ] trueReflect.set on frozen -> false | Reflect.defineProperty -> falseObject.defineProperty throws: Cannot define property b, object is not extensibleTwo good reasons to use Reflect inside traps:
- It returns
trueorfalseinstead of throwing, which is exactly what a trap must return.Object.definePropertythrows on failure. - It takes a
receiver, which matters for getters and inheritance. A getter that usesthisshould see the proxy, not the raw target. Passingreceiveralong keeps that right. Reflect.ownKeysincludes symbols and non-enumerable keys, unlikeObject.keys.
Limits and traps of Proxy
Private fields break. A private #n field belongs to the real object, not to the proxy:
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());TypeError: Cannot read private member #n from an object whose class did not declare it1Inside 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:
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:
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);{ name: 'Asha', tags: [ 'a', 'b', 'c' ], address: { city: 'Delhi' } }true truename 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:
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]); }{ 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
Datebecame a string.undefinedand the function were dropped.NaNandInfinitybecamenull.Map,Set,RegExpandErrorbecame{}. - A
BigIntor 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:
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);[ '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 trueThe 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
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 })));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 }| Thing | What structuredClone does |
|---|---|
function, symbol value, WeakMap, WeakSet | throws DataCloneError |
| class instance | becomes a plain object: the prototype is gone, so methods and getters are missing (p instanceof Point is false, p.double is undefined) |
| getter | read once, stored as a plain value (value: 5) |
| inherited properties | dropped ({ own: 2 }) |
| DOM node | throws DataCloneError in a browser (from the specification; I did not run it in a browser) |
Error | kept, with message and cause (I ran a RangeError with a cause: it was still a RangeError, bad, c) |
ArrayBuffer | copied, 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
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 });{ 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 }| Pitfall | What happened |
|---|---|
| Shallow | the 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 sources | strings and arrays spread into index keys; null and numbers give nothing |
| Getters | the getter ran and the copy holds the plain value 42, not a getter |
| Classes | own properties only: not an instance of P, and no methods |
| Frozen source | the copy is not frozen: spread gives normal, writable properties |
| Non-enumerable | skipped |
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':
'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));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 falseThe top level is locked. But cfg.db.host and cfg.list changed freely, because freezing is shallow too. A recursive deepFreeze fixes it:
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); }TypeError: Cannot assign to read only property 'host' of object '#<Object>'TypeError: Cannot add property 2, object is not extensibleMore limits I tested:
- Sloppy mode fails silently. The same write on a frozen object in a non-strict function returned the old value
1with no error. Modules and classes are strict, so you will see errors there. - A frozen
Mapstill changes.Object.freeze(new Map([[1, 2]]))thenm.set(3, 4)gave a size of2. Freezing guards properties, not internal slots. - Frozen arrays reject mutating methods:
fa.sort()threwTypeError: 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,
readonlyandas constcheck 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?".
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);{"theme":"dark","lang":"en"} {"theme":"light","lang":"en"}unchanged parts shared: true true | changed: false falseYou 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
| Goal | Non-mutating way |
|---|---|
| add at end / start | [...arr, x] / [x, ...arr] |
| remove by value | arr.filter(v => v !== 2) |
| replace one | arr.map(v => v === 3 ? 30 : v) |
| insert in the middle | [...arr.slice(0, i), x, ...arr.slice(i)] |
| remove a key from an object | const { name, ...rest } = obj |
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);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:
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());[ 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,
numswas still[3, 1, 2]. The oldsort()changed it and returned the same array (r === nums). with(index, value)is the non-mutatingarr[index] = value. A negative index counts from the end, and an out-of-range index throwsRangeError. The spread alternative would just append.toSpliced(1)with only a start removed everything from index 1. The signature is the same assplice.- Without a compare function,
toSortedsorts as text, just likesort:[5,1,10]became[1, 10, 5]. Always pass(a, b) => a - bfor numbers. - On a sparse array the copying methods turn holes into
undefined, butsortkeeps the hole. - These methods copy the array shallowly. The elements are the same objects, so
toSortedon an array of objects still shares those objects.
Try it
const a = { n: { v: 1 } }; const b = { ...a }; b.n.v = 2;What isa.n.v? Fix it soastays unchanged, withoutstructuredClone.- Write
class ValidationErrorwith afieldproperty and acause. Show how to print the whole chain of causes. - Create a Proxy that makes reading a missing property throw
ReferenceError('no such field: x'). What do you need to be careful about? - Why does
structuredClone(new Date())work butstructuredClone(() => 1)throws? - Remove the item with
id: 2fromstate.todoswithout mutating. Then do it with the new methodtoSpliced. - You froze
const cfg = Object.freeze({ list: [] }). A teammate callscfg.list.push(1)and it works. Why?
Answers
a.n.vis2, because the spread is shallow andnis shared. Fix:const b = { ...a, n: { ...a.n, v: 2 } }.class ValidationError extends Error { constructor(field, msg, options) { super(msg, options); this.name = 'ValidationError'; this.field = field; } }thenfor (let e = err; e; e = e.cause) console.log(e.name, e.message);. The loop ends whencauseisundefined.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.xthrew as wanted, andconsole.log(p)still worked because Node's printing only looks up symbols and own keys. ButJSON.stringify(p)threwno such field: toJSONandawait pthrewno such field: then, because those operations look uptoJSONandthenfirst. Allow those names, or the proxy breaks tools that probe objects.structuredCloneknows built-in data types such asDate,MapandRegExp, but a function is code with a closure and cannot be copied this way. It throwsDataCloneError, as I ran.state.todos.filter(t => t.id !== 2). WithtoSpliced:const i = state.todos.findIndex(t => t.id === 2); state.todos.toSpliced(i, 1). Check thati !== -1first, becausetoSpliced(-1, 1)would remove the last item.Object.freezeis shallow. The arraycfg.listis a separate object that was not frozen. UsedeepFreeze, orObject.freezethe 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
finallyalways runs. Do not return from it. ThrowErrorobjects, keepcause, and subclass for your own types.try/catchonly catches synchronous errors in the block. For promises useawaitinside thetry, or.catch. An unhandled rejection ends a Node 22 process.- A
Proxyruns traps for get, set, delete,in, keys and calls. Call the matchingReflectfunction inside. Private fields, invariants and speed are the limits. - Spread,
assign,sliceandconcatare shallow. The JSON trick breaks dates,Map,undefinedand cycles. structuredCloneis the deep copy tool. It keeps built-in types and cycles, but loses prototypes and refuses functions.Object.freezeis shallow and silent in sloppy mode. UsedeepFreezeor types.- Update immutably by copying the path and sharing the rest. Use
toSorted,toSpliced,toReversedandwithfor 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
- Request and Response Design: JSON Shapes, Errors and Validation · API Design
- Error Handling: Operational vs Programmer Errors · Backend in Depth
- Structural Patterns: Adapter, Decorator, Facade, Proxy and Composite · Design Patterns & LLD
Part of the study guide
Related series