Compatibility
Published
This is one of the most common things a protected build gets blamed for, and one of the least likely to be its fault. Measured across five engine configurations, every form of receiver binding came through unchanged. The report almost always turns out to be a receiver bug that was already there, made visible by a stack trace that is harder to read.
The report that usually turns out to be something else
The shape of the bug report is consistent. Something works in development, the release build is protected, and a method starts seeing the wrong this. Because protection is the only thing that changed, protection is the suspect.
It is worth being precise about what a renaming pass can and cannot reach, because the answer settles most of these reports without any debugging. Renaming rewrites identifiers: variable names, parameter names, function names, and property names when you explicitly enable member renaming. The this keyword is none of those. It is not an identifier binding, it cannot be declared, shadowed by a rename, or captured by a name table.
What determines this is the shape of the call expression at the moment of the call. A call written as obj.method() binds obj. The same function called as a bare fn() binds nothing. That distinction lives in the syntax of the call, and renaming obj to a leaves a.method() with exactly the same shape.
What was measured
A sample covered every binding form in one file: a method called on its object, the same method extracted to a bare variable and called, call and apply with an explicit receiver, bind including a second call attempting to override the bound receiver, partial application through bind, an arrow function capturing the enclosing receiver, an arrow inside a forEach callback mutating the outer object, the thisArg parameter of forEach, a constructor function with a prototype method, method shorthand, a computed method name, and a chain of methods returning this.
The file was run in Node, protected, and run again, on the default ES5 target, the modern target, a realistic renaming build, that renaming build on the modern target, and a string-table build. All five matched the original line for line.
The details worth naming: the bound function still ignored a later call attempting to rebind it, partial application still produced T/1/2/3, the arrow still reported the enclosing object's tag, the forEach arrow still accumulated onto the outer object to reach 6, and the prototype method still satisfied both instanceof and an identity check on Object.getPrototypeOf.
Arity survives, which is a better signal than it looks
One measured result deserves its own note because it is a sharper test than it first appears. A three-parameter function reported length of 3, and the function produced by binding two of its arguments reported length of 1. Both values were identical after protection.
That matters because Function.prototype.length is derived from the declared parameter list. If a transform had rewritten a parameter list, injected a helper parameter, or turned named parameters into reads from arguments, the number would have moved. It did not, in any of the five configurations.
Any library that inspects arity therefore keeps working. That includes a surprising amount of ordinary code: middleware stacks that distinguish a three-argument error handler from a two-argument handler, dependency injection that counts parameters, and test frameworks that decide whether a test is asynchronous by whether it declares a callback parameter.
Arrow functions and the ES5 rewrite
Arrow functions are the one place where the ES5 target has real work to do. An arrow has no receiver of its own and resolves this lexically from the scope that created it. ES5 has no such construct, so the rewrite must capture the enclosing receiver into an ordinary variable and read that variable inside the rewritten function.
That capture variable is then renamed along with everything else, which is precisely why it stays correct: the declaration and every read are rewritten together, as one binding, by the same pass that renames every other local. The measured output was identical on the ES5 target and the modern target, including the case that most often exposes a broken capture, an arrow inside a callback mutating a property of the enclosing object.
Sloppy-mode behaviour came through as well. A plain function called with an explicit undefined receiver still had that receiver substituted with the global object, reporting a type of object rather than undefined, exactly as before protection. If your code depends on that substitution, the thing that changes it is a directive rather than a transform.
Where a receiver bug actually comes from
If this is wrong in a protected build, it is almost always wrong in the unprotected build too, and the measurement gives you a fast way to prove which.
The most common cause is method extraction. Assigning const handler = obj.method and passing handler to an event listener or a timer discards the receiver, because the call is now a bare call. Measured, that returned undefined before protection and undefined after: the behaviour is identical, and it was always a bug. Passing obj.method.bind(obj), or wrapping it in an arrow, fixes it in both builds.
The second cause is a callback that needs the outer receiver but is written as a plain function rather than an arrow. Again the behaviour matches across builds. What genuinely does change is diagnosis: in a protected build the stack trace names renamed functions, so the frame that would have told you which callback misbehaved is less legible. That is a symbolication problem rather than a semantics problem, and it has its own tooling.
The direct test is to run the failing path in an unprotected build of the same code. If it fails there too, protection is not involved. If it genuinely only fails when protected, narrow to a single transform, since each is an independent switch, and that turns a vague report into a reproducible one.
Frequently asked questions
Can renaming change which object a method sees as this?
No. The this keyword is not an identifier binding, so a rename table has nothing to rewrite. What determines the receiver is the shape of the call expression, and renaming obj to a leaves a.method() with the same shape as obj.method().
Do arrow functions still capture the enclosing this on the ES5 target?
Yes. The ES5 rewrite captures the enclosing receiver into an ordinary variable and reads it inside the rewritten function, and that variable is renamed as one binding along with its reads. Measured output was identical on both targets, including an arrow inside a forEach callback mutating the outer object.
Do call, apply and bind behave the same after protection?
Yes, in all five configurations measured. A bound function still ignored a later call that tried to rebind it, and partial application through bind still produced the same result.
Does protection change Function.prototype.length?
No. A three-parameter function still reported 3 and a partially applied version still reported 1. That matters for middleware stacks, dependency injection and test frameworks that inspect arity to decide how to invoke a function.
My protected build sees the wrong this. What should I check first?
Run the same code path in an unprotected build. Method extraction, such as assigning obj.method to a bare variable and passing it to a listener, discards the receiver identically in both builds. If it fails in both, the receiver bug predates protection; bind the method or wrap it in an arrow.
Why does the problem only look new after protection?
Because the stack trace changed, not the semantics. Renamed function names make the frame that identifies the misbehaving callback less legible, which makes an existing bug newly visible. That is a symbolication concern rather than a behavioural one.
Related reading