Compatibility

Does obfuscation break class private fields?

Modern class syntax added a member that is genuinely private to the class body rather than private by convention. That is a language feature rather than a naming style, and it forces every transform that touches member names to make a decision about it. Here is what those decisions are, and the one case where getting them wrong produced no error at all.

A private name is not a property name

The distinction is easy to state and easy to forget. When you write this.count you are reading a property, and there is a second way to reach the same thing: this["count"]. The two forms are interchangeable, which is exactly what lets a member transform rewrite dotted access into computed access and pull the key out into a table.

A private name does not work that way. When you write this.#count, the #count is not a string key that happens to start with a hash. It is a separate lexical thing, scoped to the body of the class that declared it, and this["#count"] reads an ordinary property called #count which is a completely different slot that is almost certainly undefined. There is no computed form. There is no way to reach a private name through a variable.

That single fact drives most of what follows. Any transform whose method is to convert obj.member into obj["member"] has to recognise private names and leave them alone, because the rewrite it performs everywhere else is not merely riskier here, it is a different operation with a different meaning. The member documentation states this outcome directly: private names keep dotted access rather than being rewritten, because a hash-prefixed member is not reachable through a computed key.

The related syntax follows the same rule. A brand check written as #count in obj asks whether an object carries that private slot at all, which is a question you cannot phrase about a string key. It is one of the few ways to test an object's provenance that cannot be spoofed by copying a shape, and it survives protection for the same structural reason.

What the tokenizer does with the hash

There are two places in the pipeline that read your source, and they treat the hash differently. That is worth knowing, because the difference is the origin of a real defect that used to take whole files down.

In the main reader, a hash that is not the start of a shebang line produces a single identifier token whose text includes the sigil. #count arrives as one token reading #count, not as a punctuation token followed by a name. That is the tidy arrangement, and it is why the member transform can skip private names cleanly: they simply do not look like the thing it rewrites.

The separate pass that handles modern syntax tokenises on its own terms, and there the hash is its own token sitting in front of the identifier. Anything walking backwards through a member-access chain in that pass has to know to absorb it. When it did not, a chain like this.#o?.a was read as though the operand were just o, and the rewritten optional-chaining expression was spliced in after the orphaned hash. The output contained a hash followed by an open parenthesis, which does not parse, and the file failed to protect rather than producing something subtly wrong. The walk now absorbs the hash before continuing.

The reason to tell that story is not the bug, which is fixed. It is the shape of the risk. Private names are the case where a transform's ordinary assumption about member access stops holding, so they are the case worth checking first when a class-heavy file behaves oddly.

Private members stop the class being downlevelled

There is a pass that can rewrite a modern class into older function-based JavaScript for compatibility. It declines to do that for classes carrying certain features, and the list is precise: private fields and private methods, decorators, auto-accessor fields declared with accessor, generator members, computed or literal member names, and static initialisation blocks.

The reasoning is in the engine and it is the right call. Those features have no faithful representation in the older form. Lowering a class with a private field would leave a reference to that field dangling with nothing to bind it to, and the output would be invalid rather than merely different. Skipping the rewrite leaves a native class, which modern engines run and the parser accepts, so un-lowered output is valid output. Emitting broken JavaScript to satisfy a target level would be strictly worse.

The practical consequence lands on your browser support matrix rather than on your build. If you were relying on that pass to widen the range of engines your protected output runs on, a single private field in a class opts that class out of it. The output is still correct; it just requires an engine that understands class syntax. If you support browsers old enough for this to matter, the answer is to handle downlevelling in your own toolchain before protection, which is the ordering most projects want anyway.

The subtle one: a class field is not a binding

This is the case worth reading carefully, because it failed silently and its trigger overlaps precisely with private fields.

A class field is modelled internally as a variable declaration. That is a reasonable way to represent static x = 1 or an instance field, but it creates a trap: the name of a class field is a property, not a binding. Renaming the declaration while every C.x reference elsewhere stays a member access, correctly untouched, detaches the two halves from each other. The field lands under an obfuscated name and the read returns undefined. Nothing throws at build time and nothing throws at run time. You get a wrong value.

The renamer now whitelists real binding keywords, so only var, let and const declarations are treated as renameable and anything else is left alone. Whitelisting rather than blacklisting is the load-bearing choice: an unexpected keyword fails safe by being skipped instead of being renamed on a guess.

The overlap with this article's subject is the interesting part. The problem only surfaced when a class was not lowered, because lowering turns a field into a plain property assignment that was never renameable in the first place. And the classes that are not lowered are exactly the ones listed above, which is to say the ones containing a private member, a decorator, an accessor, a generator member, a computed name or a static block. A private field in a class was therefore enough to expose every static field on that class to the silent-undefined case.

What to check in ten minutes

Find your private members. Search your source for a hash immediately followed by a name inside class bodies. If the count is zero, none of this applies to you and you can stop here.

Confirm the classes still construct. Instantiate each class that has private state and read one public accessor that depends on it. A brand check or a private read that fails will throw a clear TypeError rather than failing quietly, so this test is cheap and definite.

Read every static field of those classes. This is the silent case. Compare each C.x against the unprotected build rather than merely checking that it is not undefined, because a field that was always undefined looks identical to one that got detached.

Check your target engines. If a class declined lowering, its output needs an engine that supports class syntax. Confirm that matches the browsers you actually support, and move downlevelling earlier in your pipeline if it does not.

Reserve the members the outside world calls. Private members are not the exposure here, by definition. Public members that something other than your own code calls by name are, and they are covered by the usual reserved-name rules.

The short version

Private fields do not break under protection, and the reason is structural rather than a special case someone remembered to add. A private name is a different kind of thing from a property name, it cannot be reached through a computed key, and so the member transform has nothing sensible to do with it except leave it alone.

The two things worth carrying away are secondary effects. A class with a private member will not be rewritten into older JavaScript, so check your support matrix rather than assuming the pass ran. And class fields are properties wearing the clothes of declarations, which is why the renamer refuses to touch anything that is not a genuine binding keyword. Test static field reads against an unprotected build once, and you have covered the only case in this area that fails without telling you.

Frequently asked questions

Does obfuscation break class private fields?

No. A private name is not reachable through a computed key, so the member transform that rewrites dotted access into bracket access recognises private members and leaves them with dotted access. That is a structural consequence of the language rather than a rule someone added, which is why it holds consistently. Brand checks written as a private name followed by the in operator survive for the same reason. The cases worth testing are secondary: whether the class was downlevelled, and whether any static class fields read back correctly.

Why can a hash-prefixed member not be renamed like other members?

Because there is no computed form to rename it into. An ordinary property can be written as dotted access or as a string key, and the two are interchangeable, which is what allows a transform to move the key into a table. A private name is scoped to the class body that declared it and has no string equivalent at all. Reading the same text as a string key gives you an ordinary property with a hash in its name, which is a different slot and is almost always undefined.

Does a private field stop my class being converted to older JavaScript?

Yes, and deliberately. The pass that rewrites modern classes into function-based JavaScript declines any class containing private fields or methods, decorators, accessor fields, generator members, computed or literal member names, or a static initialisation block. None of those have a faithful representation in the older form, and lowering them would emit invalid output such as a dangling private reference. The class is left as native class syntax instead, which is valid and which modern engines run, so the effect is on your minimum supported engine rather than on correctness.

Are static class fields safe when the class uses private members?

They are now, and this is the one case in this area that used to fail without any signal. A class field is represented internally as a variable declaration, but the field name is a property rather than a binding, so renaming the declaration while member reads stay untouched would detach them and the read would return undefined with no error. The renamer only treats var, let and const declarations as renameable, so class fields are left alone. Because the issue only appeared on classes that were not lowered, and private members are what prevents lowering, it is still worth comparing static field reads against an unprotected build once.

Do static initialisation blocks work in a protected build?

Yes. A static block is one of the features that causes the class-lowering pass to decline, so the class keeps its native syntax and the block runs as written. As with private members, that means the output requires an engine that understands modern class syntax. If you need broader engine support, run your own downlevelling step before protection rather than relying on the lowering pass to handle a class it has correctly refused.

Should I add private members to a reserved names list?

No, and doing so would not be meaningful. Reserved name lists exist for members that something outside your code calls by name, such as a serialisation key, a framework hook or a browser callback. A private member cannot be reached from outside the class body at all, which is the entire point of the feature, so nothing external can depend on its name and nothing renames it. Spend the reserved list on public members that cross a boundary.

Related reading