Compatibility

Does Obfuscation Break Duck Typing and Capability Checks?

A great deal of JavaScript decides what an object is by asking what it can do. Does it have a then method, so I can await it? Does it have render and destroy, so I can treat it as a component? Does it have every field on my required list? These checks read member names rather than calling them, which puts them in a different risk category from ordinary method calls. We measured the whole family, and the split between what is safe and what is not turns out to be sharp and easy to state.

What was measured

The sample contains three shapes of check. The first is thenable detection written the way the specification describes it, testing that a value is an object or a function and that its then property is callable, run against a real promise, a hand-built object with a then method, and a plain object that should be rejected. The second is a component check that requires two callable members before treating an object as a renderer. The third is a validator that walks a list of required field names and uses the in operator to report which are missing, plus a hasOwnProperty call and a pair of typeof reads on a present and an absent member.

It was run unprotected in node, then protected in five configurations and run again, with output diffed line by line: the ES5 target on defaults, the modern target, the two identifier-renaming presets our emit-validation gate uses, and a string-table preset that moves and encodes every literal.

All five produced output identical to the original. Promise detection stayed true, the custom thenable stayed true, the plain object stayed false, the renderer check stayed true and its method still returned the right string, the validator still reported exactly one missing field, the in checks still reported true and false correctly, hasOwnProperty stayed true, and typeof still reported function for the present member and undefined for the absent one.

That is the answer for identifier renaming, string encoding and both language targets: this style of code is not affected. The interesting half of the measurement is the member-renaming column.

Renaming your own contract is safe

Pointing member renaming at render and destroy, the two names the component check requires, produced output identical to the original.

That is worth stating plainly because it is the case most people expect to fail. The capability check reads typeof x.render === 'function', and render is defined on an object literal a few lines above in the same file. Both the definition and the check are rewritten to the same generated name, so the check still finds what it is looking for and the subsequent call still reaches the right function.

The rule this illustrates is the one that governs the entire area. A member name that is written and read entirely inside code the engine can see is renamed on both sides at once, and nothing observable changes. Renaming is a consistent substitution, not a deletion.

Renaming a language contract: a promise stops looking like one

Pointing the same option at then produced a measured, silent, wrong answer. Thenable detection against a real promise flipped from true to false, on both targets.

The reason is that only one side of that comparison is yours. The check typeof x.then === 'function' lives in your file and is rewritten to a generated name. The then method it is looking for lives on Promise.prototype, which is part of the language and is not yours to rename. The renamed read finds nothing, the object is judged not thenable, and your code takes whatever path it takes for plain values.

Nothing throws here, and that is what makes it worth writing down. A function that quietly stops recognising promises does not produce a stack trace; it produces a value that was supposed to be awaited and was not. If that value is then stored, serialised or compared, the symptom surfaces somewhere else entirely.

The same reasoning covers the other well-known protocol names. Anything the language, the platform or a library reaches for by name is a contract with a party that cannot participate in your rename.

Renaming against your own string literals, which is the common accident

The third case is the one we expect most readers to hit, because it does not require any exotic code at all.

The validator keeps its required field names in an array of strings and checks them with the in operator. Point member renaming at those field names and the declarations on the object are renamed while the string literals in the array are not, because a string is a value rather than a member access site. The measured result: a validator that previously reported one missing field now reports all three missing, the in check on a field that plainly exists returns false, and hasOwnProperty returns false for a property the object still has under a different name.

Every one of those is a silent wrong answer rather than an error. A validator that reports everything missing tends to be believed, and the bug is then hunted in the data rather than in the build.

This is the same shape as the well-documented case of obj["count"] surviving while obj.count is renamed. It is worth internalising as one rule rather than several: if a name appears anywhere in your source as a string, renaming its declaration desynchronises the two.

How to keep the useful half

None of this is an argument against member renaming. It is an argument for scoping it, and the scope has a clean definition: rename names that only your own code reaches by dot access, and leave everything else alone.

In practice that means writing an anchored allowlist rather than a broad pattern. A regular expression that names your internal fields explicitly cannot accidentally match then, and it cannot match the field names you also keep in a strings array. A pattern that matches by shape rather than by enumeration eventually will.

Before you ship, grep your source for the names you intend to rename and look at what comes back. Any hit inside a string literal, a required-fields list, a schema, a translation key or a serialised payload is a name to remove from the pattern. That grep takes a minute and it is the whole audit for this area.

It is also worth keeping the capability checks themselves honest. Detection code that tests for a member and then calls it in the same breath fails loudly when the member is missing, which is much easier to diagnose than detection that silently routes around it.

The short version

Duck typing and capability checks are unaffected by identifier renaming, string encoding and both language targets. We measured promise detection, component detection and a required-fields validator, and all of them behaved identically after protection in five configurations.

Member renaming is the option that can break them, and the boundary is exactly where the site's general rule puts it. Names your own code both defines and reads are renamed consistently and keep working. Names owned by the language, such as then, and names that also appear as string literals in your own source, such as a required-fields list, desynchronise and produce silent wrong answers. Scope the pattern to the first category and this area is safe.

Frequently asked questions

Does obfuscation break duck typing?

Not through identifier renaming, string encoding or either language target. We measured promise detection, a component capability check and a required-fields validator across five configurations and every result was identical to the unprotected run. Member renaming is the only option in this area that changes behaviour.

Why does my promise stop being recognised after protection?

Because member renaming matched then. The check in your file is rewritten to a generated name while Promise.prototype.then is part of the language and cannot be renamed, so the lookup finds nothing and the value is judged not thenable. We measured this flip from true to false on both targets, and it happens silently rather than throwing.

Why does my validator report that every required field is missing?

Because the required names are held as string literals while the object's properties were renamed. Member renaming rewrites declarations and dot accesses, never strings, so the two sides stop agreeing. We measured a validator go from reporting one missing field to reporting all three, with the in operator and hasOwnProperty both returning false for properties that still exist.

Is it safe to rename the members my own capability checks read?

Yes, when both the definition and the check are in code the engine can see. Renaming the two members a component check requires produced output identical to the original, because both sides were rewritten to the same generated name. Renaming is a consistent substitution within the file, which is why the self-contained case is the safe one.

Does the in operator behave differently after protection?

The operator itself is untouched, and it returned correct answers in all five configurations. It only appears to misbehave when member renaming has changed a property's name while the string you are testing with stayed as written, in which case the operator is answering a question about a name that no longer exists.

How do I choose a safe member-renaming pattern?

Enumerate your internal field names in an anchored allowlist rather than matching by shape, then grep your source for each name before shipping. Any occurrence inside a string literal, a schema, a required-fields list or a serialised payload means that name has a second reader the transform cannot rewrite, so it belongs outside the pattern.

Related reading