The JavaScript Proxy: a stand-in that is not its target
A proxy is another object: every operation passes its traps, and nothing private comes with it.
Predict first
Before you read: predict
ExamHall's proctor console records which members of an attempt it reads, through a proxy:
In Node 24, what does main.ts print?
// attempt.tsexport class Attempt { #deadline: number; constructor(public id: string, deadline: number) { this.#deadline = deadline; } minutesLeft(now: number): number { return this.#deadline - now; }} // main.tsimport { Attempt } from './attempt';const reads: string[] = [];const attempt = new Attempt('att-7', 600);const watched = new Proxy(attempt, { get(target, key, receiver) { reads.push(String(key)); return Reflect.get(target, key, receiver); },});try { console.log(`left ${watched.minutesLeft(540)}`);} catch (e) { console.log(`threw ${(e as Error).name}`);}console.log(`reads: ${reads.join(', ')}`);The 30-second version
A JavaScript Proxy is a new object that stands in for a target: each operation on it goes to a trap, and Reflect performs the default. It is not the target. Its identity differs, and a method called through it runs with this set to the proxy, which has none of the target's private fields.
A proxy is another object: every operation passes its traps, and nothing private comes with it.
How it works
Step 1 of 11
new Proxy(target, handler) returns a new object. Reads, writes, in checks, deletes and calls on it go to the handler.In the kitchen
- the double stands in for the star, and the crew calls "action" the same way → a proxy offers the target's interface (step 1)
Analogy: A stunt double on a film set (film production)
| On set | In code |
|---|---|
| the double stands in for the star, and the crew calls "action" the same way | a proxy offers the target's interface (step 1) |
| the stunt coordinator handles the scenes on his list; any other scene goes to the star herself | a trap per operation; no trap, the target (step 2) |
| for a listed stunt he checks the rig, then the scene is shot as scripted | a trap adds its step, then Reflect does the default (step 3) |
| the star's dressing room opens to her own key only | private fields stay on the original (step 7) |
| the fan club's list names the star, never the double | a separate identity (step 9) |
| for the close-up the double cannot do, the director calls the star herself | a method bound to the target (step 8) |
In real terms: a proxy is a second object placed in front of the first; whatever reaches the first does so through code the proxy runs, and whatever belongs only to the first stays out of reach.
Breaks down when: a close look tells a double from the star. Plain JavaScript has no isProxy: only identity tells a proxy from its target.
Contrasting case: the star doing her own stunts. When no operation needs controlling, hand over the object itself.
Examples, run for real
4.1 ExamHall EXH-4820 and MR !5120: a caching proxy over the question bank
EXH-4820: ExamHall's candidate screens read questions through cached(), a proxy that remembers each method call's result. The same helper was then put in front of Attempt, whose deadline is a private field:
// cached.ts (as shipped)
export function cached<T extends object>(target: T): T {
const memo = new Map<string, unknown>();
return new Proxy(target, {
get(t, key, receiver) {
const value = Reflect.get(t, key, receiver);
if (typeof value !== 'function') return value;
return (...args: unknown[]) => {
const k = `${String(key)}(${args.join()})`;
if (!memo.has(k)) memo.set(k, value.apply(receiver, args));
return memo.get(k);
};
},
});
}
MR !5120 changes one call: value.apply(t, args), so a remembered method runs on the target.
4.1 files: cached.ts, bank.ts, attempt.ts, main.ts · tsc no diagnostics
4.1 EXH-4820 as shipped: 4 reads of q1, q2, q1, q2 · real reads 2 · cached(attempt).minutesLeft(540) threw TypeError
4.1 MR !5120 (value.apply(t, args)): real reads 2 · cached(attempt).minutesLeft(540) returns 60
The bank's four reads of two questions made two real reads. Through the shipped helper, minutesLeft ran with this = the proxy and threw (steps 6–7).
4.2 Vue and Immer, on their real packages
4.2 @vue/reactivity 3.5.44: reactive(raw) === raw false · reactive(raw) twice gives one proxy true · toRaw(proxy) === raw true
4.2 an effect that reads cart.qty: runs 1 · after cart.note = 'none' 1 · after cart.qty = 2 2
4.2 immer 11.1.18: the recipe's draft is a Proxy true · draft === base false
Vue's reactive() hands back a proxy, never the object itself, and the same proxy each time; an effect re-ran only after a write to a property it had read (steps 9–10). Immer's recipe received a proxy as its draft (step 11).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A payments ledger hands read-only views of its accounts to reporting code. readOnly keeps one view per object it is given:
What does main.ts print?
// guard.tsconst made = new WeakMap<object, object>();export function readOnly<T extends object>(t: T): T { const known = made.get(t); if (known) return known as T; const view = new Proxy(t, { set() { return false; } }); made.set(t, view); return view;} // main.tsimport { readOnly } from './guard';const account = { id: 'acc-9', balance: 1200 };const shown = new Set<object>();shown.add(account);shown.add(readOnly(account));shown.add(readOnly(account));shown.add(readOnly(readOnly(account)));console.log(`entries ${shown.size}`);Question 2 of 7
A chat app lets a sender edit a message's text but never its send time:
main.ts is compiled TypeScript, so it runs in strict mode.
What does it print?
// message.tsexport function guarded<T extends object>(m: T, log: string[]): T { return new Proxy(m, { set(target, key, value, receiver) { if (key === 'sentAt') { log.push(`refused ${String(key)}`); return false; } log.push(`set ${String(key)}`); return Reflect.set(target, key, value, receiver); }, });} // main.tsimport { guarded } from './message';const log: string[] = [];const msg = guarded({ text: 'helo', sentAt: 1000 }, log);try { msg.text = 'hello'; msg.sentAt = 2000; msg.text = 'hello!';} catch (e) { log.push(`threw ${(e as Error).name}`);}console.log(log.join(' · '));console.log(`text ${msg.text} · sentAt ${msg.sentAt}`);Question 3 of 7
A box office records which fields its seat map reads:
What does main.ts print?
// seat.tsexport class Seat { constructor(public row: string, public num: number, public held: boolean) {} get label(): string { return this.held ? `${this.row}${this.num} held` : `${this.row}${this.num}`; }} // watch.tsexport function watched<T extends object>(t: T, reads: string[]): T { return new Proxy(t, { get(target, key) { reads.push(String(key)); return Reflect.get(target, key); }, });} // main.tsimport { Seat } from './seat';import { watched } from './watch';const reads: string[] = [];const seat = watched(new Seat('F', 12, true), reads);const text = seat.label;console.log(`reads: ${reads.join(' → ')}`);Question 4 of 7
A video player keeps its position in a private field. The team records every write made through the player's proxy:
What does main.ts print?
// playback.tsexport class Playback { #position = 0; lastAction = 'none'; seek(seconds: number): number { this.#position = seconds; this.lastAction = 'seek'; return this.#position; }} // main.tsimport { Playback } from './playback';const writes: string[] = [];const playback = new Proxy(new Playback(), { get(target, key) { const value = Reflect.get(target, key); return typeof value === 'function' ? value.bind(target) : value; }, set(target, key, value) { writes.push(String(key)); return Reflect.set(target, key, value); },});try { playback.seek(90); playback.lastAction = 'pause';} catch (e) { writes.push(`threw ${(e as Error).name}`);}console.log(`writes: ${writes.join(', ')} · lastAction ${playback.lastAction}`);Question 5 of 7
The interviewer says: "ExamHall builds a Grader only when the first answer is marked; it keeps its rubric in private fields. Write lazy(create) that returns a stand-in for the grader." create() runs on the first read, write or in check, and only once. Reads, writes and in checks reach the real grader; its methods and getters work, private fields and all; an assignment through the stand-in succeeds in strict code. You have 15 minutes and a plain editor.
Say your test list first, then write it.
Question 6 of 7
A chat app's unread badge uses a small store with the shape Vue's docs describe: a get trap that records which effect read which property, and a set trap that re-runs those effects when a value changes:
Two messages arrive and the badge still shows only 0.
Which change makes main.ts print badge: 0, 1, 2, with store.ts left as it is?
// store.tstype Effect = () => void;let running: Effect | null = null;const deps = new Map<string, Set<Effect>>();export function reactive<T extends object>(t: T): T { return new Proxy(t, { get(target, key, receiver) { if (running) { const k = String(key); if (!deps.has(k)) deps.set(k, new Set()); deps.get(k)!.add(running); } return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { const old = Reflect.get(target, key, receiver); const ok = Reflect.set(target, key, value, receiver); if (!Object.is(old, value)) deps.get(String(key))?.forEach((e) => e()); return ok; }, });}export function watchEffect(fn: Effect): void { running = fn; fn(); running = null; } // room.tsimport { reactive } from './store';export const roomData = { name: 'support', unread: 0 };export const room = reactive(roomData); // socket.tsimport { roomData } from './room';export function onMessage(): void { roomData.unread += 1; } // main.tsimport { room } from './room';import { watchEffect } from './store';import { onMessage } from './socket';const badge: number[] = [];watchEffect(() => { badge.push(room.unread); });onMessage();onMessage();console.log(`badge: ${badge.join(', ')}`);Question 7 of 7
A payments ledger's correction screen runs a fix written by another team on an entry, and its audit trail must list the fields the fix touched:
recordChanges(entry, fix) must list every field the fix changes, adds or removes (here touched: amount, note, held), and leave entry changed as correct changed it, with correct.ts left as it is.
How should it find them?
// correct.ts (the other team's code)export function correct(e: Record<string, unknown>): void { e.amount = 1250; e.note = 'fee refunded'; delete e.held;} // record.ts (the stub the screen calls today)export function recordChanges(entry: Record<string, unknown>, fix: (e: Record<string, unknown>) => void): string[] { fix(entry); return [];} // main.tsimport { correct } from './correct';import { recordChanges } from './record';const entry: Record<string, unknown> = { id: 'pay-31', amount: 1200, currency: 'EUR', held: true };console.log(`touched: ${recordChanges(entry, correct).join(', ')}`);Go deeper
Pro tips
- Hand out access you can take back with
Proxy.revocable. Afterrevoke(), every operation on the proxy throws, while the target stays usable for whoever holds it. When: giving a plugin or a third-party script temporary access to an object. Verify (RAN,tips.mjs): the first line below. - In Node,
util.types.isProxytells a proxy apart while debugging. The language itself has no such check. When: a value behaves oddly in a debugger and you suspect a framework's proxy. Verify (RAN,tips.mjs): the second line below.
tip 1 Proxy.revocable: read before revoke() dark · read after threw TypeError · target still reads dark
tip 2 util.types.isProxy(proxy) true · util.types.isProxy(target) false · globalThis.isProxy undefined
Source
Every graded behaviour is executed, so no item is keyed on these pages; they back the teaching. Valid as of 2026-10-17.
- MDN, Proxy (the row's first source), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy (
mdn-proxy.txt). "The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object." (step 1); "Handler functions are sometimes called traps , presumably because they trap calls to the target object." (step 2); "A proxy is still another object with a different identity — it's a proxy that operates between the wrapped object and the outside. As such, the proxy does not have direct access to the original object's private elements ." (steps 7, 9); internal slots: "it does not work for native objects, such as DOM elements, Map objects, or anything that has internal slots." (step 7). - MDN, Reflect.get() (not named by the row; teaching only), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get (
mdn-reflect-get.txt). Onreceiver: "The value of this provided for the call to target if a getter is encountered. Defaults to target ." (step 5). - Vue, Reactivity in Depth (the row's third source), https://vuejs.org/guide/extras/reactivity-in-depth.html (
vue-reactivity-in-depth.txt). "In Vue 3, Proxies are used for reactive objects and getter / setters are used for refs." and, of itstrack()andtrigger(), "Inside track() , we check whether there is a currently running effect." (step 10). - Vue, Reactivity Fundamentals (not named by the row; teaching only), https://vuejs.org/guide/essentials/reactivity-fundamentals.html (
vue-reactivity-fundamentals.txt). "To ensure consistent access to the proxy, calling reactive() on the same object always returns the same proxy, and calling reactive() on an existing proxy also returns that same proxy". - The row's second source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), is a book that cannot be fetched as text, and keys nothing; the proxy's intent is taught by
pat-structural-wrappers.
Related lessons
Checked against the tools and docs as of 2026-10-17.