What survives
Published
Almost every question that starts “why did protection break this?” and every question that starts “why is this still readable?” have the same answer, which is that renaming is narrower than people assume. Here is the inventory, and the reason each item is on it.
Regular expression literals
A pattern literal is a single token and comes out byte for byte identical, named capture groups and all. It is on this list for two reasons: it explains why a pattern-driven bug is never caused by the transform, and it means anything you encoded into a pattern is readable in your shipped bundle.
The corollary is that a pattern built from a string is a string, and goes into the string table like any other when string moving and encoding are enabled.
Import and export specifiers
Module specifiers survive verbatim, and they have to. An import specifier is required by the language to be a static literal, so it is reassembled as raw text rather than decomposed into something renameable. Your dependency graph is therefore visible in a protected bundle, which is exactly what a scanner or an SBOM tool relies on.
The exception is a dynamic import(), which takes an expression. Its specifier is an ordinary string and is packed like one, so the value is preserved but the text stops being greppable.
Property names you supply as strings
This is the single most useful item to know. Member renaming rewrites a property name in a small, specific set of places: an identifier key in an object literal, a string key in an object literal, and dotted member access. Bracket access with a string key is not one of them.
So obj.count is a renaming candidate and obj["count"] is not, and the two are no longer the same slot after renaming. That asymmetry is behind most reported metaprogramming failures, and it is also the lever you use deliberately: read wire-format fields with bracket access and they are safe by construction.
The same logic covers any API where you name a property with a string. Anything that takes a key as data rather than as syntax is working with the original name, while the dotted reads around it may have moved.
Label names
Labels live in their own namespace and are never renamed, including when the loop they label is rewritten into a different shape by the older output target. They are on this list mostly so you can stop suspecting them.
Private member names
A private class member keeps its # name. The reader treats the sigil and the name as one token, so a private name does not look like the dotted access that member renaming rewrites, and obj["#count"] is a genuinely different slot rather than a way to reach it.
Relatedly, a class whose body contains a private member, a decorator, an accessor or a static initialisation block is deliberately not lowered by the older output target, because lowering it would emit invalid code. Leaving a native class intact is better than emitting something that does not run.
Exported names, and the names your callers depend on
An exported binding keeps its name, because renaming it would break the contract with whatever imports it. This is worth knowing beyond correctness: an exported class keeps its name in a heap snapshot, so exported and file-local classes look different when you are profiling.
The general principle underneath the whole list is the one to take away. A name is renameable when the protector can see every reference to it. A name that crosses a boundary it cannot see, into another module, into a server contract, into a string handed to an API, into a pattern, is a name it must leave alone. When protection appears to break something, look for the boundary the name crossed. When something is still readable that you expected to be hidden, you have found the same boundary from the other side.
Frequently asked questions
Why is obj["count"] still readable when obj.count was renamed?
Because member renaming rewrites a property name in exactly three places: an identifier key in an object literal, a string key in an object literal, and dotted member access. Bracket access with a string key is not one of them, so the two stop referring to the same slot after renaming.
Are my dependencies visible in a protected bundle?
Yes. Static import and export specifiers are required by the language to be literals and are carried through as raw text, so the module graph is visible. That is what makes a protected build scannable for an SBOM. A dynamic import takes an expression, so its specifier is an ordinary string and can be packed into the string table.
Which names can the protector safely rename?
The ones where it can see every reference. A binding used only inside the file, and a property accessed only through syntax it rewrites, are safe. A name that crosses a boundary it cannot see, into another module, into a server response, into a string handed to an API, has to be left alone.
Do private class fields get renamed?
No. A private name is read as one token including the # sigil, so it does not look like the dotted access that member renaming rewrites. A class containing a private member, decorator, accessor or static block is also deliberately not lowered by the older output target, because lowering it would emit code that does not run.
Why do exported class names still appear in a heap snapshot?
Because exported names are preserved so that importers keep working. A file-local class can be renamed and an exported one cannot, which is why the two look different when you are profiling memory.
How do I stop the transform renaming fields that come from my API?
Either exclude them by pattern from member renaming, or read them with bracket access using a string key, which is never rewritten. The second approach is safe by construction and does not depend on keeping an exclusion list in step with your API contract.
Related reading