What survives

Does obfuscation break JavaScript symbols?

Symbols are used for precisely the things a protection pass touches: property keys and protocol hooks. That makes them look fragile. Measured against the real engine in five configurations, symbol-keyed properties and the coercion protocols come through byte-for-byte identical in behaviour, and the reason is structural rather than lucky.

Why symbols look like they should break

A symbol is an opaque, unique property key. Code reaches for one in two situations: to attach a slot to an object without risking a name collision, and to implement one of the language's protocol hooks, such as Symbol.toPrimitive or Symbol.toStringTag. Both of those are property keys, and property keys are exactly what member renaming rewrites.

So the worry is reasonable. If a transform can turn order.total into order.a7, what stops it turning cache[SLOT] into something the reader no longer recognises? And if the well-known symbol keys were rewritten, an object would quietly stop coercing the way it used to, which is the kind of failure that shows up as a wrong string in a log rather than as an exception.

The answer is that symbol keys sit outside every rename site by construction. That is a stronger guarantee than a transform choosing to skip them, because there is no configuration in which it starts happening.

What was measured

A sample was built that exercises the whole surface at once: a symbol-keyed data property, a string key and a dotted property alongside it, Symbol.toStringTag, a Symbol.toPrimitive implementation that returns different values for the string and number hints, Symbol.hasInstance on a plain object, a registry symbol from Symbol.for, and a symbol used as a private-ish slot.

That file was run in Node, then protected by the engine and run again, with the outputs compared line by line. Five configurations were used: the default ES5 target, the modern target, a realistic renaming build (identifier replacement plus global renaming), the same renaming build on the modern target, and a string-table build with MoveStrings and EncodeStrings enabled.

All five produced output identical to the original. The symbol read returned its value, String(obj) gave str while +obj gave 42 from the same Symbol.toPrimitive hook, Object.prototype.toString.call(obj) reported [object Custom] from the tag, Object.getOwnPropertySymbols still found all three symbols, and 4 instanceof EvenMatcher was still true while 3 instanceof EvenMatcher was false.

Why it holds: a symbol key is never a rename site

Member renaming rewrites a property name in exactly three places. It rewrites an identifier key in an object literal, it rewrites a string key in an object literal (quoting a key does not opt it out), and it rewrites a dotted member access. Those are the three, and a computed member access is not among them.

Symbol keys are always computed. There is no dotted form for a symbol: you cannot write obj.someSymbol and mean the symbol, only obj[someSymbol]. The same is true of every well-known symbol in an object literal, which must be written as [Symbol.toPrimitive](hint) with brackets. So every syntactic form a symbol key can take is a computed one, and computed forms are not rename sites.

This is the same asymmetry that makes obj["count"] survive while obj.count is renamed, seen from the other side. Where that asymmetry is a hazard for string keys, because two spellings of the same access diverge, it is a guarantee for symbols, because a symbol has only the one spelling.

It also explains why the result is stable across configurations. Nothing about the ES5 rewrite, the string table or global renaming introduces a new place where a computed key could be treated as a name, so all five builds agree.

The coercion protocols still fire

The well-known symbols are worth calling out separately, because their failure mode is silent. If Symbol.toPrimitive stopped being found, an object would fall back to the default coercion path and produce [object Object] where the code expected a formatted value. Nothing throws. A template literal simply renders the wrong text.

Measured, the hook was still found and still received the correct hint: the string hint path and the number hint path both ran and returned their distinct values. Symbol.toStringTag was still honoured by Object.prototype.toString. Symbol.hasInstance installed on a plain object still took over the instanceof operator for that object.

The registry behaved too. Symbol.for("app.key") returned a symbol that compared equal to a second Symbol.for call with the same string, and Symbol.keyFor returned the original key. The description of a plain symbol survived, so String(sym) still produced Symbol(mine).

Symbols are not a hiding place, before or after protection

The one belief worth correcting is the idea that a symbol slot is private, and that protection makes it more so. It does not, and it never did.

Object.getOwnPropertySymbols lists every symbol key on an object, and it works exactly as well on a protected build as on an unprotected one; the measurement above depends on that fact. Browser developer tools display symbol-keyed properties alongside string ones. A symbol keeps a caller from colliding with your key by accident, which is what it is for, and it does nothing to keep a determined reader from finding it.

The description string is worth a separate note. It is an ordinary string literal, so in a string-table build it moves into the table with every other literal and is produced by a call at runtime rather than sitting in the source as plain text. That raises the effort of reading it out of the file; it does not remove it, because anything the running program can produce, a reader with a console can also produce.

The practical rule is the one that applies to everything else on the client: a symbol organises your code, it does not hold a secret. Anything that must stay unknown to the person running the browser belongs behind an API call.

Scope, and what still deserves a test

This article covers symbol-keyed properties and the coercion and matching protocols: toPrimitive, toStringTag and hasInstance. It does not cover the iteration protocol, which turns on how a value is consumed rather than on how a key is spelled and deserves its own treatment.

If your own code leans on symbols, the check is cheap and worth running once rather than reasoning about. Write a scratch file that prints the values your protocol hooks produce, protect it with the same configuration your build uses, run both under Node, and compare the two outputs. That is exactly the measurement above, and it takes a couple of minutes against your own configuration rather than a generic one.

The result to expect is no diff. If you do see one, the useful next step is to narrow which configuration produced it, because the transforms are independent switches and the answer is almost always a single one of them rather than protection as a whole.

Frequently asked questions

Does member renaming rename symbol-keyed properties?

No. Member renaming rewrites a property name in three places: an identifier key in an object literal, a string key in an object literal, and a dotted member access. A symbol key can only ever be written as a computed access with brackets, so it falls outside all three by construction rather than by exception.

Do Symbol.toPrimitive and Symbol.toStringTag still fire after protection?

Yes. Measured by running the engine and comparing output, a toPrimitive hook still received the correct hint and returned its distinct string and number values, and toStringTag was still honoured by Object.prototype.toString. This held on the ES5 target, the modern target, renaming builds and a string-table build.

What happens to a symbol's description string?

It survives, so String(sym) still produces the same text. In a string-table build the description is treated like any other string literal and is produced by a call at runtime instead of appearing as plain text in the file, which raises reading effort without removing the value.

How much privacy does a symbol key give a protected build?

None beyond collision avoidance. Object.getOwnPropertySymbols enumerates symbol keys in a protected build just as it does in an unprotected one, and developer tools display them. A symbol keeps other code from colliding with your key; it is not a place to keep something the user must not learn.

Does Symbol.for still return the same registry symbol?

Yes. A registry symbol fetched twice with the same key still compared equal after protection, and Symbol.keyFor still returned the original string key.

How should I check symbol behaviour in my own build?

Write a scratch file that prints what your protocol hooks return, protect it with your project's own configuration, run the original and the protected file under Node, and diff the two outputs. Expect no difference; if one appears, narrow it to a single transform, since each is an independent switch.

Related reading