Compatibility

Does Obfuscation Break Function.name and Reflection?

Most compatibility questions on this site have a reassuring answer: we measure the behaviour, nothing moves, and the report turns out to be an older bug in a new suit. This one is different. Identifier renaming changes what a function reports about itself, and it is supposed to. The useful question is not whether fn.name changes but which of your code paths read it, because two common patterns depend on it and fail quietly.

What was measured

The sample declares the same trivial function nine ways: a function declaration, an anonymous function expression assigned to a variable, a named function expression, an arrow function, a class, an object literal method, a getter, and a bound function. It then reads name and length off each one, reads constructor.name off an instance and off its prototype, keys a registry by fn.name, and pulls the parameter names out of the function's own source text the way an implicit dependency injection annotator does.

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

Three of the five configurations produced output identical to the original. The ES5 target on defaults, the modern target and the string-table preset do not rename identifiers, so every function still reports the name it was written with. The two renaming presets changed exactly the lines you would expect them to change, and left the rest alone. That split is the whole answer: this is an identifier-renaming question, not a protection question.

What renaming changes, precisely

Under the renaming presets, declared.name came back as a generated identifier instead of declared. So did the anonymous function expression, which had been taking its name by inference from the variable it was assigned to, and so did the arrow function for the same reason. A named function expression reported the generated form of its inner name rather than inner. A class reported a generated name, and so did instance.constructor.name and the constructor reached through Object.getPrototypeOf, which is consistent: those are three ways of reading one binding.

A bound function still reported the bound prefix, with the generated name after it, so code that tests for that prefix keeps working while code that compares the whole string does not.

Reading parameter names out of the source text returned generated identifiers as well. The sample takes the text between the first parentheses of Function.prototype.toString and splits it, which is exactly the technique an implicit annotator uses, and it returned two generated names where it had returned a and b.

The registry line is the one worth pinning on the wall. The sample registers a function under fn.name and then looks it up by the literal string "declared". Before protection the lookup succeeds. After renaming it fails, because the key written into the registry is now a generated name while the string literal used to look it up is untouched. Nothing throws. A registry lookup that returns nothing is a feature that silently stops appearing.

What survived, and why that matters more than it looks

Arity did not move. declared.length stayed at 2, the arrow function stayed at 3, and the class stayed at 1 across every configuration. Renaming changes what a parameter is called, never how many there are, so any dispatch that branches on fn.length behaves the same before and after.

The method defined in an object literal kept its name under identifier renaming, because an object literal key is a member name rather than a binding, and identifier renaming does not reach members. That is a different option with a different reach, covered below.

Function.prototype.toString still returns a string, and that string still contains the function keyword. What changed is the text inside it: our probe for the original expression a + b stopped matching, because the parameters had been renamed and the body rewritten with them. So source-text reflection keeps working mechanically while the content it reads has changed, which is the worst combination for debugging, since nothing fails until something downstream compares the text to an expectation.

Built-in functions still report [native code]. That marker is load-bearing for the anti-tampering option, which reads it back through Function.prototype.toString to decide whether a global has been replaced, and Function.prototype.toString is itself on that option's default watch list.

The two patterns that actually break

The first is any registry, factory or plugin table keyed by fn.name or constructor.name. Component libraries that register by class name, error handlers that switch on err.constructor.name, and serialisers that write a type tag from the constructor all share this shape. The fix is to key on a string you supply, because a string literal is data and is never a rename site. A static type property or an explicit registration name costs one line and removes the dependency entirely.

The second is implicit dependency injection: any framework that reads parameter names out of the function source to decide what to inject. AngularJS made the pattern famous and its own documentation has warned against the implicit form for as long as minifiers have existed, because a minifier breaks it for the same reason a protector does. Every such framework ships an explicit form, usually an array of names alongside the function, and the explicit form survives protection untouched.

Both failures are quiet. Neither throws at build time, and the injection failure typically surfaces as an unrelated error deep inside the framework. If you are auditing a codebase before its first protected release, searching for .name reads on functions and classes is a cheap, high-yield pass.

Member renaming is a separate question

Everything above concerns identifier renaming. Member renaming is a different option, scoped by a pattern you supply, and it can reach this area from the other side: we measured a pattern of ^(name|length)$ turning every reflection read in the sample into undefined, because the reads themselves were rewritten to a generated member that nothing defines.

That is not a surprise so much as a reminder of what the pattern means. name and length are properties of every function in the language, so a member pattern that matches them rewrites reads of the language's own surface. Keep them out, along with any other name that belongs to the platform rather than to you.

One measured caveat for anyone relying on class arity: on the modern target with member renaming enabled we saw a class's length change from 1 to 0 even under a pattern that matched none of its members. If your code branches on the declared arity of a class, verify it on your own build rather than assuming, and prefer the ES5 target or an explicit registration value if it matters.

How to check your own build

The check is short enough to paste into a release script. Take the handful of functions and classes whose names your code reads, print fn.name, fn.length and String(fn).slice(0, 40) for each, and run it against an unprotected and a protected build of the same commit. Anything in the second column that your application compares against a literal is a bug waiting for a release.

Then grep the source for the reads themselves: .name on a function or class value, constructor.name, and any call to toString on a function. In most codebases the list is short and mostly logging, which is the good outcome, since a log line with a generated name is a readability problem rather than a correctness one, and source maps solve it.

Frequently asked questions

Does obfuscation change what Function.name returns?

Identifier renaming does, and it is meant to. We measured function declarations, anonymous and named function expressions, arrow functions and classes all reporting a generated identifier once names were renamed, along with constructor.name read from an instance or through Object.getPrototypeOf. The ES5 default configuration, the modern target and the string-table preset left every name intact, because none of those renames identifiers.

Does protection change the number of parameters a function reports?

No. We measured fn.length identical in every configuration: 2 for a two-parameter declaration, 3 for a three-parameter arrow function, 1 for a class constructor. Renaming changes what a parameter is called, not how many there are, so dispatch that branches on arity behaves the same before and after.

Why did my plugin registry stop finding anything after protection?

Almost certainly because it is keyed by fn.name. Our sample registers a function under its own name and looks it up with a string literal; after renaming, the key stored is a generated identifier while the lookup string is unchanged, so the lookup returns nothing and nothing throws. Key registries on a string you supply, or a static property, rather than on the function's name.

What happens to dependency injection that reads parameter names?

It gets generated names. We pulled parameter names out of Function.prototype.toString the way an implicit annotator does, and after renaming it returned generated identifiers instead of the originals. Every framework that supports the implicit form also supports an explicit form, usually an array of names next to the function; the explicit form is a string literal, so it survives protection unchanged.

Does Function.prototype.toString still work on protected code?

It still returns a string, and for built-ins it still contains the [native code] marker, which matters because the anti-tampering option reads that marker back to detect a replaced global. What changes is the text of your own functions: parameter names are generated and the body is rewritten, so any code comparing that text against an expected shape needs revisiting.

How much does this area cost to audit before a first protected release?

Very little. Grep for .name reads on function and class values, for constructor.name, and for toString calls on functions. In most codebases the hits are logging, which source maps handle. The two hits worth acting on are name-keyed registries and implicit dependency injection, and both have a one-line explicit form.

Related reading