Compatibility

Does Obfuscation Break Error Codes and errno?

Error handling in Node is not written against messages. Messages change between versions and between locales, so the advice everyone follows is to branch on the code instead: if (err.code === 'ENOENT'). That line looks like the most ordinary property access in the file, and it is -- which is why it is worth measuring, because a property access is exactly what member renaming rewrites, and code is a name your build never wrote.

What was measured

One sample file, driven through five protection profiles for the base column and through both member-renaming profiles for the rest. It reads a file that does not exist and dispatches on the resulting code; reads a directory as if it were a file, to get a second, different code from the same API; constructs an invalid URL to get an error from a different subsystem entirely; and finally runs the retry decision that most network and filesystem code has somewhere, testing the runtime's code against the application's own list of codes considered transient.

Nothing in the sample writes code, errno, syscall, path or name on any of those error objects. Node's filesystem and URL layers write them before the catch block ever runs. That is the property that makes the measurement worth anything: if the file under test had assigned those names itself, renaming would have rewritten the assignment and the test together, everything would have stayed self-consistent, and every arm would have reported no difference at all -- truthfully, and uselessly.

The base column first, because it decides whether any of this is a protection question. On the default target, the modern target, the gate profile, the modern gate profile and the string-transform profile, the sample's output was identical to the unprotected original, character for character. Error codes, errno values, syscall names and error names all survive protection untouched. If you protect with the default configuration, none of what follows applies to you.

The dispatch does not fail. It takes the other branch

With member renaming on and a pattern matching code, the sample printed code=undefined where the original printed code=ENOENT. The emitted file reads err._0x1, node wrote code, and there is no such property, so the read produces undefined.

The next line is the one that matters. The sample's dispatch went from branch=treat-as-missing-use-defaults to branch=RETHROW-unexpected-io-failure. This is the shape of the failure worth internalising: the comparison did not error, it simply evaluated false, because undefined === 'ENOENT' is false the way any mismatched comparison is. A missing optional config file -- the single most common thing an ENOENT branch exists to absorb -- now travels up the stack as an unexpected I/O failure, and whatever sits at the top of that stack treats it as a crash.

Both other codes went the same way in the same run: dir-code=EISDIR became undefined, and url-code=ERR_INVALID_URL became undefined. It is worth being precise about what that does and does not mean. The two errors do not become equal to each other; they become unrecognised. Every equality test against a string code returns false, so every dispatch built out of such tests falls through to whatever its final else branch does. If that branch rethrows, you get noisy crashes for conditions you already handle. If it swallows, you get silence for conditions you meant to escalate.

The retry decision inverts, and that arm had to be built to prove it

The first version of this sample tested the retry helper against the ENOENT error only. That arm reported retry=false before protection and retry=false after it, which looks like a clean result and proves nothing at all: ENOENT is not in the transient list, so the answer was always going to be false, and the arm could not distinguish a retry policy that stopped working from one that was never going to fire.

The sample was extended so that one of the runtime's codes is genuinely in the application's transient list. With that in place the measurement is unambiguous: retry-transient=true became retry-transient=false, in the same run in which retry-enoent=false stayed false. A transient, retryable failure is now classified as permanent and is not retried.

The mechanism is the same asymmetry this site keeps arriving at from different directions. The list of transient codes is the application's own data and is unaffected. The value tested against it is the runtime's, read through a renamed property, and is undefined. A set membership test for undefined in a list of strings returns false for every error, so the retry path becomes unreachable -- not slower, not flakier, unreachable. Nobody writes a test for the case where their backoff never engages.

instanceof still works, which is what makes this hard to spot

One arm is worth reporting on its own because it explains why this class of problem survives review. With a pattern matching name, the sample printed err-name=undefined where the original printed err-name=TypeError -- and in the same run, is-typeerror=true was unchanged.

instanceof walks the prototype chain and does not consult any property name, so it is untouched by member renaming. err.name is an ordinary property read and is not. A codebase that checks error types with instanceof keeps working perfectly; the logging layer next to it, which writes err.name into every record, starts writing undefined. Half of your error handling continues to behave, which is the worst possible signal, because the half that broke is the half you only read after something has already gone wrong.

The diagnostic fields behave the same way and are worth listing plainly, because they are what an on-call engineer actually reads. A pattern matching syscall and errno produced syscall=undefined errno=undefined. A pattern matching path produced path-tail=undefined, which is to say the error report no longer tells anyone which file was involved.

What this means in practice

The headline is narrow and should not be inflated. Protection does not break error handling. Five profiles, including the two gate profiles and the string transform, reproduced the sample's output exactly. If you are running a default configuration this is not a surface you need to think about.

With member renaming on, the rule is the same one that governs every externally supplied name: code, errno, syscall, path and name on error objects are written by the runtime, not by your build, and they belong in your reserved list rather than in your rename pattern. The standing advice on this site -- anchor member patterns to names distinctive to your own application rather than sweeping short common identifiers -- handles this case automatically, because a pattern anchored that way will not match a bare code in the first place.

The check is one line and needs no test framework. In the protected artifact, not in the source, force an error you know the code for and print the code you read back. If it prints undefined where you expect a string, the property was renamed. Do it for one filesystem error and one error from a different subsystem, and you have covered the whole family in a single run -- and you will have measured the value where it crosses the boundary out of the runtime and into your code, which is the only place this defect is visible at all.

Frequently asked questions

Does obfuscation break Node error codes?

Not in the default configuration. A sample that dispatches on err.code, reads errno, syscall and path, checks an error's name, and runs a retry decision against a list of transient codes produced identical output on all five protection profiles measured. Error codes only become a surface when member renaming is switched on and the pattern matches them.

Why is err.code undefined after protection?

Because the property was renamed and the runtime still writes the original name. Node's filesystem and URL layers set code before your catch block runs, so they are not recompiled when your bundle is. The emitted file reads err._0x1, no such property exists on the error object, and the read produces undefined.

What happens to an if (err.code === 'ENOENT') check?

It evaluates false and the else branch runs. Nothing is thrown. In the measured sample the dispatch went from treat-as-missing-use-defaults to RETHROW-unexpected-io-failure, so a missing optional file -- the exact case that branch exists to absorb -- began travelling up the stack as an unexpected failure.

Can member renaming stop my retry logic from firing?

Yes, and it was measured directly rather than inferred. With a transient code genuinely present in the application's retry list, retry-transient went from true to false while a non-transient error stayed false in the same run. The list of codes is your own data and is unaffected; the value tested against it is undefined, and undefined is never in a list of strings, so the retry path becomes unreachable.

Do instanceof checks on errors still work?

Yes. instanceof walks the prototype chain and consults no property name, so it is untouched. In the arm where err.name read as undefined, the instanceof check on the same error was still true. That split is the reason this class of problem survives code review: type-based error handling keeps working while the logging beside it starts recording undefined.

Are errno, syscall and path affected the same way?

Yes. A pattern matching syscall and errno produced syscall=undefined errno=undefined, and a pattern matching path removed the filename from the error report. These are the fields an on-call engineer reads first, and they are written by the runtime rather than by your build.

How do I keep error codes working with member renaming on?

Keep runtime-written names out of the rename pattern: code, errno, syscall, path and name on error objects belong in the reserved list. Anchoring the member pattern to identifiers distinctive to your own application does this automatically. To verify, force a known error in the protected artifact and print the code you read back -- undefined where you expect a string means the property was renamed.

Related reading