A JavaScript Proxy's handler defines an `ownKeys` trap that returns `['a', 'b']`, yet `Object.keys(proxy)` returns an empty array. Which other trap is involved, and why does the result come back empty?
answer
- one syntax, two internal operations
- enumerability has to come from somewhere
- the second lookup hits the bare target
- descriptors must not claim permanence
basics
~20 sObject.keys also calls the getOwnPropertyDescriptor trap for every key that ownKeys reported, to test enumerability. With that trap absent it forwards to the target, which has no such properties, so each key returns undefined and is filtered out.
solid answer
~40 s`Object.keys` is not a single operation. It performs `[[OwnPropertyKeys]]` — your `ownKeys` trap — and then, for each string key it got back, `[[GetOwnProperty]]` to find out whether that property is enumerable. That second step is the `getOwnPropertyDescriptor` trap. Since the handler does not define it, it falls through to the target, the target has no `a` or `b`, the descriptor comes back `undefined`, and the key is dropped. Result: `[]`. The fix is to trap `getOwnPropertyDescriptor` too and return a descriptor with `enumerable: true` — and `configurable: true`, because a proxy is not allowed to report a property as non-configurable when it does not exist on the target. The same pairing drives `for...in`, object spread, `Object.entries` and `JSON.stringify`; `Object.getOwnPropertyNames` skips the filter, so it shows both keys.
code
javascript · 18 linesconst target = {};
const broken = new Proxy(target, {
ownKeys: () => ['a', 'b'],
});
console.log(Object.keys(broken)); // []
console.log(Object.getOwnPropertyNames(broken)); // ['a', 'b']
const fixed = new Proxy(target, {
ownKeys: () => ['a', 'b'],
getOwnPropertyDescriptor: () => ({
value: 1,
enumerable: true,
configurable: true,
}),
});
console.log(Object.keys(fixed)); // ['a', 'b']
console.log(JSON.stringify(fixed)); // {"a":1,"b":1}go deeper
Know that Object.keys returns only own enumerable string keys, and that on a proxy the keys and the enumerable flag can come from two different handler methods.
Be able to name both traps in order and say why the untrapped second one consults the target. Also know Object.getOwnPropertyNames skips the filter, which is how you prove ownKeys ran.
Show that partially trapping an object is the real defect: explain how spread, JSON.stringify, for...in and in each reach the object through different internal methods, and how you would debug the inconsistency by logging per trap.
Be ready to judge whether a synthetic-key facade is worth shipping at all, given that every enumeration path must be kept consistent by hand and any consumer using an operation you did not anticipate sees a different object.
## `Object.keys` is two internal operations, not one The habit of thinking "`Object.keys` calls `ownKeys`" is what makes this result surprising. The specified algorithm is: perform `[[OwnPropertyKeys]]` on the object; then, for each key that is a string (symbols are skipped), perform `[[GetOwnProperty]]` on that key; keep the key only if a descriptor came back *and* its `enumerable` flag is true. On a proxy, `[[OwnPropertyKeys]]` is the `ownKeys` trap and `[[GetOwnProperty]]` is the `getOwnPropertyDescriptor` trap. Trapping only the first half means the second half quietly runs against the target — and the target knows nothing about the keys you invented. ```js const p = new Proxy({}, { ownKeys: () => ['a', 'b'], }); Object.keys(p); // [] <- filtered away Object.getOwnPropertyNames(p); // ['a', 'b'] <- no filter step JSON.stringify(p); // '{}' { ...p }; // {} ``` `Object.getOwnPropertyNames` is the control case: it returns the string keys straight from `[[OwnPropertyKeys]]` with no enumerability test, so it proves the `ownKeys` trap really did run. ## The fix, and why `configurable: true` is mandatory ```js const p = new Proxy({}, { ownKeys: () => ['a', 'b'], getOwnPropertyDescriptor: (target, key) => ({ value: key.toUpperCase(), enumerable: true, configurable: true, }), get: (target, key) => String(key).toUpperCase(), }); Object.keys(p); // ['a', 'b'] Object.entries(p); // [['a', 'A'], ['b', 'B']] JSON.stringify(p); // '{"a":"A","b":"B"}' ``` Dropping `configurable: true` does not merely change the descriptor — it throws a `TypeError`. The engine enforces an invariant on the `getOwnPropertyDescriptor` trap: a property may not be reported as non-configurable unless it actually exists on the target as a non-configurable own property. `a` does not exist on the target at all, so claiming it is permanent is a lie the engine refuses. `enumerable: false` is legal but pointless here, since it puts you back at `[]`. Note also that a descriptor object you return is normalised: omitted fields default to `false`/`undefined`, so `{ value: 1 }` alone means non-enumerable, non-writable and non-configurable — and therefore throws for the same reason. ## Which operations use which pairing It is worth memorising the shape of the common key-walking operations, because they hit different combinations of traps: - `Object.keys` / `Object.values` / `Object.entries` — `ownKeys`, then `getOwnPropertyDescriptor` per key, and for values/entries a `get` per surviving key. - Object spread `{ ...p }` and `Object.assign(dst, p)` — the same three: keys, enumerability check, then read. Symbols are included here, unlike `Object.keys`. - `JSON.stringify(p)` — walks own enumerable string keys the same way, then reads each value. - `for...in` — `ownKeys` and `getOwnPropertyDescriptor` on the proxy, then `getPrototypeOf` to continue up the chain and repeat there. - `Object.getOwnPropertyNames` / `Object.getOwnPropertySymbols` / `Reflect.ownKeys` — `ownKeys` only, no filtering. - `'a' in p` — `has` only; it never consults `ownKeys`. So a proxy that answers `has` truthfully but never reports keys is invisible to enumeration, and a proxy that reports keys without descriptors is invisible to `Object.keys`. Interviewers like this question precisely because it forces you to say which operation is built from which primitive. ## Rules the `ownKeys` trap itself must obey The trap result is validated before it is used: - It must be an array-like whose entries are all strings or symbols — returning numbers throws a `TypeError`. - It must include every own key of the target that is non-configurable. - If the target is non-extensible (after `Object.preventExtensions`, `Object.seal` or `Object.freeze`), the result must be exactly the target's own keys — no additions, no omissions. That last rule is why the invented-keys trick only works over an extensible target. Freeze the target and the same handler starts throwing. ## Debugging heuristic When a proxy "loses" properties, the productive question is never "why is my trap wrong" but "which trap did this operation actually call". Log the key inside each trap and run the operation once; you will see `ownKeys` fire alone, and the absence of `getOwnPropertyDescriptor` in the log is the whole bug. The general lesson generalises past this one case: implementing a *partial* set of traps is how proxies behave inconsistently, because different syntax reaches the object through different internal methods.
- What does `for...in` over that same proxy do differently from `Object.keys`?It uses the same `ownKeys` plus `getOwnPropertyDescriptor` pair on the proxy, so the broken version yields nothing there either — but it then calls the `getPrototypeOf` trap and repeats the whole walk on each prototype, so inherited enumerable properties also appear. `Object.keys` stops at own properties and never touches `getPrototypeOf`.
- Why does the invented-key handler start throwing if you freeze the target first?Freezing makes the target non-extensible, and the `ownKeys` invariant then requires the trap result to be exactly the target's own keys. Reporting `a` and `b` over a frozen empty object is an impossible claim, so the engine throws a `TypeError` instead of using the result. Proxy invariants are checked against the target's real state, not against what the handler asserts.
- If the handler traps `getOwnPropertyDescriptor` but not `get`, what does `Object.entries(proxy)` return?Keys and enumerability come from your traps, but each value is fetched with `[[Get]]`, which without a `get` trap runs on the target. So the keys appear with values of `undefined` — the descriptor's `value` field is used for the filter, not as the answer to a later read. Reporting a descriptor and answering a read are separate obligations.
saying these in an interview costs you the question
- Thinks Object.keys only calls the ownKeys trap
- Believes ownKeys alone makes invented keys enumerable
- Returns { value: x } and expects it to be enumerable
- Reports a synthetic property as non-configurable and is surprised by the TypeError
- Assumes 'a' in proxy consults ownKeys rather than has