Compatibility
Published
Error handling is the least exercised code in most applications. It runs when something has already gone wrong, it rarely has tests, and when it misbehaves the symptom is an absence rather than a failure. That combination makes it worth measuring separately, because a transform that quietly damages the error path will not announce itself until you need the error path most.
What was measured
The sample builds a two-level cause chain the way production code does: a JSON.parse failure is caught and rethrown as new Error('config unreadable', { cause: e }), that is caught and rethrown as new Error('boot failed', { cause: e }), and the top-level handler walks the chain. It also constructs an AggregateError with two errors and a message, and serialises a chain the way a crash reporter would.
It records the things people actually depend on: the messages at each level, the constructor name of the root error, the depth of the chain, whether cause is an own property, whether it is enumerable, whether an error built without a cause invents one, and what JSON.stringify does to the whole thing.
In all five configurations the output was identical. The chain was two deep, the root was still a SyntaxError, cause was an own but non-enumerable property, an error constructed without a cause did not have one, the AggregateError reported both of its errors with the right constructor names, and the serialised chain read exactly as it did before protection.
One line of that output is worth pausing on, because it surprises people who then blame the protection step: JSON.stringify(err) returns {}, before and after. Error properties are non-enumerable, so the serializer has nothing to copy. If your logging pipeline stringifies errors and gets empty objects, that is the language, not the obfuscator.
The first break: cause is read by the constructor, not by you
The cause option looks like an ordinary property, and that is exactly what makes it a trap. You write { cause: e }, you read err.cause, and both of those are member positions that a member pattern can match.
With a pattern matching cause, the protected file is internally consistent: the object literal is written as { _0x1: e } and every read is written as err._0x1. If cause were a property you set yourself, that would work perfectly, and this is the case where the usual intuition fails.
It does not work, because you never set that property. You hand an options object to the Error constructor, and the constructor looks for a key literally named cause. It finds a generated name, concludes there is no cause, and builds an error without one. The chain is silently never created, and the first read of err.cause.message throws TypeError: Cannot read properties of undefined.
This is a sharper version of a rule this series has stated several times. Self-consistent renaming is safe when both ends of the name are inside the file. Here both ends are inside the file and it still breaks, because a third party reads the name in between.
The second break, and it is the quiet one
AggregateError behaves the same way for its list: with a pattern matching errors, the constructor still populates a property called errors, the protected code asks for a generated name, and the walk throws. Loud, immediate, easy to diagnose.
The quiet failure is different in kind. With a pattern matching message and name -- two names that look like obvious application vocabulary and match a broad pattern easily -- nothing throws at all. The chain is built correctly, the depth is right, the root error is still a SyntaxError, and every message reads undefined.
The serialised chain, which is the thing that reaches your logging backend, came out as undefined(undefined) <- undefined(undefined) <- undefined(undefined). The structure survived perfectly and the content is gone. There is no exception, no console warning and no failed request. The build is healthy by every check you are likely to have, and your crash reporter is recording nothing useful.
That is the worst combination available: a transform that damages exactly the code you rely on to tell you about damage. It is also the easiest to avoid, because message and name are language contracts and belong in any exclusion list you write.
Why the string-keyed checks disagree
The sample includes two checks written with string literals rather than dot access: Object.prototype.hasOwnProperty.call(e, 'cause') and 'cause' in plain. Under a pattern matching cause, those strings are left exactly as written, because a string literal is not a rename site.
The consequence is that the two halves of your own code stop agreeing with each other. The dot-access half is asking about a generated name; the string half is asking about cause. A guard written as if ('cause' in err) can therefore return false while err.cause would have found something, or the reverse, depending on which side was renamed.
This is worth knowing beyond error handling, because the pattern is general and it is the single most common way member renaming produces a confusing result rather than a clean crash. Any place where your code reaches a property both by dot and by string is a place where renaming can split your logic in half.
The practical reading: if you use member renaming at all, prefer one access style per property, and keep reflection over your own object shapes to a minimum.
What survives without any care at all
The base column is a genuinely clean result and worth stating clearly, because the useful conclusion is not that error handling is fragile -- it is that error handling is fine until you point member renaming at the language's own vocabulary.
Throwing, catching, rethrowing, finally, the prototype relationship that makes instanceof SyntaxError work, the non-enumerability of error properties, and the construction of an AggregateError all came through five configurations unchanged. Control flow and object identity are not what these transforms move.
Identifier renaming is not the risk here either. The local variables holding the caught errors were renamed in every configuration and the chain was still correct, because those names never leave the function.
The risk is specifically a member pattern that is written to match application vocabulary and happens to also match cause, errors, message, name or stack. Those five names are read by the runtime, by your reporter's SDK, and by every tool that formats an error for a human.
How to check your own build
Throw a chained error on purpose in the protected build and print the walk. Three lines are enough: build a nested cause chain, catch it, and log each level's name and message along with the chain depth. If any of those read undefined while the depth is right, a member pattern has reached the language vocabulary.
If you use a hosted crash reporter, the higher-value check is to send one deliberate test error from a protected build and look at what arrives in the dashboard rather than at what your code printed. The reporter's SDK reads these same names, so it can lose the message even when your own logging looks fine.
For the loud half, the check is simply that a chained error still has a cause: assert that err.cause is defined immediately after constructing one with the option. If the constructor never saw the key, that assertion fails at the point of construction rather than somewhere downstream.
Frequently asked questions
Does obfuscation break error cause chains?
Not on its own. A two-level cause chain, the root error's constructor, the chain depth, the non-enumerability of error properties and an AggregateError's list all measured identical in five configurations. The breaks measured here all came from member renaming matching names the runtime reads.
Why is err.cause undefined in my protected build?
Because a member pattern matched cause. The option object is written with a generated key and the Error constructor only understands a key literally named cause, so it builds an error with no cause at all. The renaming is self-consistent across your file and still wrong, because the constructor reads the name in between.
Why do my error messages log as undefined after protection?
A member pattern matched message or name. Nothing throws: the chain is built correctly and every message reads undefined, so a serialised chain arrives at your logging backend with its structure intact and its content gone. Add message, name, stack, cause and errors to your exclusion list.
Why does JSON.stringify on an error return an empty object?
That is standard JavaScript, not an effect of protection. Error properties are non-enumerable, so the serializer has nothing to copy, and the sample returned an empty object before and after protection. If you need to serialise an error, read the fields explicitly or use a replacer.
Does renaming break hasOwnProperty checks written with strings?
It splits them from your dot access. String literals are not rename sites, so a check written as 'cause' in err keeps asking about cause while err.cause asks about a generated name. Keeping one access style per property avoids the whole class of problem.
Is AggregateError affected in the same way?
Yes, and loudly. The constructor populates a property named errors, so a pattern matching errors leaves your code asking the aggregate for a generated name and the walk throws immediately. That is the easier failure to find; the message and name case is the one that hides.
Related reading