skip to content

Why can a JavaScript Proxy's `get` trap cause a TypeError even when the trap body itself runs without error, and what shape of property on the target triggers it?

level: seniorimportance: should knowfreq 25%

answer

  1. the engine audits what the trap returned
  2. permanence is what cannot be faked
  3. frozen targets narrow your options
  4. extensible plus configurable equals freedom

basics

~20 s

Proxies must not lie about the target's own non-configurable properties. If the target holds a non-writable, non-configurable data property, the get trap must return exactly that value; returning anything else makes the engine throw a TypeError after the trap returns.

solid answer

~50 s

The engine validates a trap's *result* against the target before handing it back, so a perfectly well-behaved trap body can still end in a `TypeError`. The rule for `get` is that if the target has an own data property that is both non-writable and non-configurable, the trap must return the same value — anything else is a claim the target has already made unfalsifiable, and the engine refuses it. The same family of checks covers the other traps: `has` cannot report `false` for a non-configurable own property, `deleteProperty` cannot report success for one, `ownKeys` must list every non-configurable own key and, over a non-extensible target, exactly the target's keys, and `isExtensible` and `getPrototypeOf` must match the target once it is non-extensible. These are the *essential internal method invariants*: a proxy may change behaviour freely, but it may not contradict guarantees the target has permanently made.

code

javascript · 10 lines
javascript
const target = {};
Object.defineProperty(target, 'id', {
  value: 42, writable: false, configurable: false,
});
target.name = 'ok';   // ordinary: writable and configurable

const p = new Proxy(target, { get: () => 'lie' });

console.log(p.name);  // 'lie'  — allowed
console.log(p.id);    // TypeError: the trap did not return the actual value

go deeper

for a junior

Know that a proxy handler is not all-powerful: the engine checks a trap's result against the target and throws a TypeError if the two contradict each other.

for a middle

Be able to state the get rule precisely — a non-writable, non-configurable own data property must be reported with its real value — and distinguish that from a merely non-writable property, which you may fake.

for a senior

Explain that the check happens on the returned value at the call site, sketch the same rule across has, deleteProperty and ownKeys, and show how to diagnose one by reading the target's descriptor flags.

for a principal

Be prepared to argue why the language enforces this at all: Object.freeze and non-configurability are guarantees other code builds on, and a wrapper nobody can see must not be able to revoke them. That principle is what makes proxies safe to hand across a trust boundary.

## Traps are checked, not trusted The intuitive model of a proxy is "the handler decides". That is almost true. Each trap is called, and then, before the result is used, the engine compares it against the real state of the target. If the result contradicts a guarantee the target has already committed to, the operation throws a `TypeError` — from the call site, not from inside your handler. These checks are called the essential internal method invariants, and they exist so that language-level guarantees survive being wrapped. The guarantee that matters is *permanence*. A configurable property can be redefined or deleted, so promising nothing about it is fine. A **non-configurable** property is a promise that it will keep existing on that object, and a non-configurable **non-writable** data property is a promise that it will keep that exact value forever. Code — including engine optimisations and security-minded code that froze an object on purpose — is allowed to rely on that. A proxy is not allowed to break it. ## The `get` case, concretely ```js const target = {}; Object.defineProperty(target, 'id', { value: 42, writable: false, configurable: false, }); const p = new Proxy(target, { get() { return 'anything else'; }, }); p.id; // TypeError: 'get' on proxy: property 'id' is a read-only and // non-configurable data property on the proxy target but the // proxy did not return its actual value ``` The trap ran fine and returned a string. The throw happens afterwards, in the proxy's `[[Get]]`, when the returned value is compared to `42` with SameValue semantics. Return `42` and the operation succeeds. There is a matching rule for accessors: if the target has a non-configurable accessor property whose getter is `undefined`, the trap must return `undefined`. Note what does *not* trigger it. A merely non-writable property (still configurable) is fine to lie about, because it could be redefined. A property that does not exist on the target at all is fine to invent. Frozen objects hit the rule hardest, because `Object.freeze` makes every own data property non-writable **and** non-configurable at once — so a proxy over a frozen object is essentially unable to fake any of its existing values. ## The rest of the invariant family Each trap carries its own version of the same idea: - **`set`** — cannot report success for a value different from a non-writable, non-configurable own data property, nor for a non-configurable accessor with no setter. - **`has`** — cannot return `false` for a non-configurable own property of the target, nor for any own property when the target is non-extensible. - **`deleteProperty`** — cannot return `true` for a non-configurable own property, nor for any own property of a non-extensible target. - **`getOwnPropertyDescriptor`** — cannot report `undefined` for a non-configurable own property; cannot report a property as non-configurable unless it exists that way on the target; and cannot invent properties on a non-extensible target. - **`defineProperty`** — cannot report success for a definition the target would have rejected. - **`ownKeys`** — must include every non-configurable own key, and over a non-extensible target must be exactly the target's own keys, no more and no fewer. - **`isExtensible`** — must agree with the target's real extensibility, always. - **`preventExtensions`** — can only report `true` if the target is genuinely non-extensible afterwards. - **`getPrototypeOf`** — may return anything while the target is extensible, but once the target is non-extensible it must return the target's actual prototype. - **`construct`** — must return an object. A useful way to compress all of this: **you can lie freely about an extensible target's configurable properties, and about nothing else.** ## Practical consequences First, if you want maximum freedom in a handler, wrap an empty extensible object and synthesise everything, rather than wrapping a real object whose shape constrains you. Many virtual-object handlers use `new Proxy({}, handler)` for exactly this reason. Second, invariant errors are late and confusing to read: the stack points at the call site, the message names the target's property, and the handler looks innocent. When you see "proxy did not return its actual value", go and inspect the target with `Object.getOwnPropertyDescriptor` — the answer is always in the descriptor's flags. Third, this is the honest answer to "can I use a proxy to intercept anything?". You can intercept every operation, but you cannot contradict what the target has permanently promised. That is a deliberate design decision: it keeps `Object.freeze` meaningful and keeps invariants that other code depends on from being silently undermined by a wrapper it cannot see.

  • Why is `isExtensible` the strictest trap of all?
    Because its result must always equal the target's real extensibility, with no escape hatch — extensibility is a one-way, permanent state change, and other invariants are defined in terms of it. If a proxy could misreport it, every rule that says "when the target is non-extensible…" could be sidestepped, so the specification pins this one absolutely.
  • How do you get the freedom to synthesise arbitrary behaviour without fighting invariants?
    Wrap a fresh, empty, extensible object: `new Proxy({}, handler)`. With no own properties and no non-extensibility, almost every invariant is vacuously satisfied, so the handler can invent keys, values and descriptors at will. The constraints come from the target's real shape, so a target with nothing in it constrains nothing.
  • Is a proxy over a frozen object still useful for anything?
    Yes, but only for adding behaviour, not for changing answers: you can log or count reads, throw on unknown keys, or restrict operations further, since making things stricter never contradicts a guarantee. What you cannot do is report different values, hide existing keys, or claim the object is still extensible.

saying these in an interview costs you the question

  • Claims a proxy can intercept and change absolutely any behaviour
  • Thinks the TypeError comes from inside the trap body
  • Confuses non-writable alone with non-writable plus non-configurable
  • Believes invariants are checked before the trap rather than on its result
  • Says wrapping a frozen object gives the handler the same freedom as any other

context