Compatibility

Does Obfuscation Break Property Descriptors?

Most of the contracts in this series belong to somebody else: a server, a library, a file format. A property descriptor belongs to the language. When you pass a dictionary to Object.defineProperty, the reader of that dictionary is the JavaScript engine, and the six names it looks for are fixed by the specification. A build step that rewrites property names is therefore rewriting the arguments of a language feature.

What was measured

Two files. The first defines a non-writable data property, a hidden non-enumerable field, an accessor pair, a bulk definition, a descriptor read-back and a clone that copies descriptors, then prints what each one produced. The second was written afterwards, for a reason given further down, and covers redefining and deleting a property and installing a test double over a method.

Both are clean in the base column. All five protection profiles reproduced the unprotected output exactly, including the write that a non-writable property silently rejects and the field that stays out of the serialised form. Protecting code that uses descriptors changes nothing on its own.

The member column is where the language becomes the reader. Every arm below matches one or two of the six descriptor keys, and each one is measured on both member-renaming profiles.

An object that serialises as empty with every value intact

The clearest result came from renaming the enumerable key alone. The property was still defined, still readable through its own name, still counted among the object's own properties. It simply stopped being enumerable, because the engine never saw the flag and applied the default, which is false.

What that produces downstream is an object whose serialised form is a pair of empty braces. Nothing throws. Every value is present and correct in memory. The listing of the object's keys comes back empty, and the bulk-defined object lost its visible key the same way. If that object is an API response body, a message payload or a record written to storage, the receiving side gets an empty document from a process that believes it sent a full one.

Renaming the value key is the mirror image and just as quiet: the property is defined with no value at all, so reads return undefined and the serialised form is empty for a different reason. The descriptor read-back agreed, reporting the value and the writable flag as undefined, which means a diagnostic written to investigate the problem reports the same emptiness rather than the cause.

An accessor that quietly becomes a value

Renaming the getter and setter keys turned an accessor pair into an ordinary data property with no value. The read returned undefined, which is visible. The write is the interesting half: assigning to the property did nothing at all, and the backing variable the setter would have updated kept its original amount.

So a setter that validated, rounded, clamped or published a change now discards writes in silence. There is no error, no rejected assignment and no warning, because a data property without a writable flag simply ignores assignment outside strict mode. A test that sets a value and reads it back through the same property will notice; a test that sets a value and asserts on the effect elsewhere is the one that catches it early.

The check that asks whether the property is still an accessor went from true to false in the same run, which is worth knowing if a framework in your stack branches on that question.

The write that creates a second property

The control arm was supposed to be inert and was the most surprising result in the file. Object.defineProperty takes the property name as a string, so the definition keeps its original name no matter what the member pattern says. The dot reads of that same property are property names, so they move.

The consequence is not a missing read but a duplicated object. The read returned undefined, as expected. The assignment that follows, which the original code performs to demonstrate that a non-writable property rejects it, created a brand new property under the generated name and stored the value there. The serialised object came out carrying both: the original field with its original value, and a generated key holding the value that was supposed to have been rejected. The count of own properties went from three to five.

Two guarantees were lost at once. The object is no longer the shape anything downstream expects, and the immutability that defineProperty was called for no longer holds, because the write did succeed, just somewhere else. This is the documented string-versus-identifier rule producing extra state rather than absent state, which is the version of it that surveys of missing fields will not find.

The flag whose loss shows up on delete

One arm came back completely inert: renaming the configurable key changed nothing in the first file. An arm expected to be inert is exactly where this series has learned not to infer, so the second file was written to find out what it had actually changed.

It had changed the property into a non-configurable one, which costs nothing until something reconfigures or removes it. Measured: deleting the property went from succeeding to being refused, silently, with the property still present afterwards. Outside strict mode a refused delete returns false rather than throwing, so a cleanup path can run to completion and leave the value behind. A descriptor read-back reported the flag as undefined.

Redefining the same property still worked, and the reason is worth spelling out: a non-configurable property may still have its value changed while it remains writable. So the loss of the flag is invisible on the operation people expect to break and visible on the one they do not test.

Then the negative control failed. A property defined without any configurable key at all was included to give the experiment a baseline, and under a pattern matching the writable key that baseline threw a TypeError on redefinition, because losing writable on an already non-configurable property removes the last route to changing it. The control produced the loudest failure in the experiment, which is the second pass running where a control has done that. In the same file, renaming the value key made a test double install a method with no value and the first call to it throw.

What to do about it

Treat the six descriptor keys as reserved words for your member pattern: value, writable, enumerable, configurable, get and set. They are read by the engine, they are the same in every codebase, and they are short enough that a broad pattern will match them by accident.

Anchoring the pattern to a private prefix covers them without an exclusion list, and it also covers the many library helpers that build descriptor dictionaries on your behalf. If you clone objects with their descriptors, or freeze and seal them, or use a framework that defines reactive properties, those dictionaries are all the same contract.

Verify with the effect rather than the definition. Serialise the object and compare the document against one you typed. Assign to a property that should reject the assignment and count the object's own properties afterwards. Delete a property and check that it is gone. Each of those was a one-line check in the measured file, and each one caught a failure that reading the property back would not have shown.

Frequently asked questions

Does obfuscation break Object.defineProperty?

Not in the default configuration. Two files covering data properties, hidden fields, accessor pairs, bulk definition, descriptor read-back, descriptor-preserving clones, redefinition, deletion and a test double all reproduced their unprotected output on all five protection profiles measured. The descriptor dictionary becomes a surface only under member renaming.

Why does my object serialise as an empty document?

Because the enumerable flag was renamed, so the engine never saw it and applied its default of false. The measured object kept every value and every property, listed no keys, and serialised as a pair of empty braces. Nothing throws, which is why the receiving side is usually where it is noticed.

What happens to a getter and setter defined through a descriptor?

They stop being an accessor. In the measured run the property became a data property with no value, so reads returned undefined and writes were discarded in silence, leaving the backing variable at its original amount. A check for whether the property was still an accessor went from true to false.

Can a renamed read create a second property?

Yes, and it was measured. Because defineProperty names the property with a string, the definition keeps its name while the dot reads move. The read returned undefined and the following assignment created a new property under the generated name, so the object serialised with both fields and its own-property count went from three to five.

Does renaming the configurable flag matter?

Not until something removes or reconfigures the property. The first measured file showed no difference at all; a second file written to test it showed that deleting the property went from succeeding to being silently refused, with the property still present afterwards. Redefinition still worked, because a writable non-configurable property may still have its value changed.

Which descriptor keys should be excluded from a member pattern?

All six: value, writable, enumerable, configurable, get and set. They are read by the JavaScript engine rather than by your code, they never change between codebases, and they are short common words that a broad pattern matches by accident. Anchoring the pattern to a private prefix removes the need to list them.

How do I test descriptor behaviour on a protected build?

Test the effect, not the definition. Serialise the object and compare it against a document you typed, assign to a property that should refuse the assignment and count own properties afterwards, and delete a property and confirm it is gone. Reading the property back through the same name will not show any of these failures.

Related reading