Skip to content

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?

Code for this question
// 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(', ')}`);
Pick one

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

A stand-in object. 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?

Code for this question
// 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}`);
Pick one

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?

Code for this question
// 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}`);
Pick one

Question 3 of 7

A box office records which fields its seat map reads:

What does main.ts print?

Code for this question
// 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(' → ')}`);
Pick one

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?

Code for this question
// 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}`);
Pick one

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?

Code for this question
// 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(', ')}`);
Pick one

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?

Code for this question
// 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(', ')}`);
Pick one

Go deeper

Pro tips
  1. Hand out access you can take back with Proxy.revocable. After revoke(), 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.
  2. In Node, util.types.isProxy tells 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). On receiver: "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 its track() and trigger(), "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.

Checked against the tools and docs as of 2026-10-17.