Compatibility

Does Obfuscation Break Intl and Locale Formatting?

Every Intl constructor takes a second argument that looks like ordinary configuration and is not. It is a dictionary the platform reads by name, and the platform's response to a name it does not recognise is not an error. It is a shrug: unknown keys are ignored and the default is used instead. That combination, silent fallback plus a renamed key, produces the quietest category of bug this series has measured.

What was measured

The sample exercises five formatters in one file: a currency Intl.NumberFormat, a percent NumberFormat, an Intl.DateTimeFormat pinned to UTC, an Intl.Collator configured for case-insensitive numeric sorting, and an Intl.PluralRules in ordinal mode. It also reads back the two structures the platform hands out: formatToParts, which returns an array of objects with type and value fields, and resolvedOptions, which reports the options the formatter actually settled on.

This is deliberately more than a formatting smoke test. Each of those calls is a contract in a different direction. The options dictionary is a name the platform reads out of an object you built. formatToParts is a name the platform writes into an object it built. The method names are the platform's. And the array being sorted is entirely yours.

Protected in five configurations -- the default target, the modern target, both gate profiles and the string-transform profile -- the output was identical every time. $1,234.50 stayed $1,234.50, the percent formatter still produced 45.7%, the UTC date still read Aug 16, 2026, the collator still sorted Item2, item9, item10 in that order, and the ordinal plural rules still returned one, two, few, other.

None of that is surprising once you look at what the transforms operate on. Locale tags and option values are string literals, and the string transforms move and encode literals without changing what they decode to. There is no syntax here for a target-version setting to lower. The formatters are ordinary constructor calls.

The options object is a dictionary the platform reads

The interesting column is member renaming, and the result is worth quoting exactly. With a member pattern matching style, currency, minimumFractionDigits and maximumFractionDigits, the emitted call reads new Intl.NumberFormat('en-US',{_0x1:'currency',_0x2:'USD',_0x3:2,_0x4:2}).

That object is still an object. It still has four properties. The values are still correct. The only thing that changed is the keys, and the keys are the entire contract. The platform looks for a property called style, does not find one, and falls back to the default numbering style. It looks for currency, does not find one, and has no reason to complain because a plain decimal format does not need a currency.

The measured result is 1,234.5 where the source produced $1,234.50. No exception, no warning, no console output. A currency amount rendered as a bare decimal, with the minor units silently truncated because minimumFractionDigits went missing along with everything else. The percent formatter degraded the same way, turning 45.7% into 0.457 -- the underlying ratio, correct as a number and wrong on the screen by a factor of a hundred.

resolvedOptions is the diagnostic that would have caught it, and it fails in a way that points straight at the cause. Reading ro.style, ro.currency and ro.minimumFractionDigits off the result returned undefined/undefined/undefined, because the platform populates that object with the real names while the protected file asks it for generated ones.

A date that lands on the wrong day

The DateTimeFormat arm is the one worth showing a colleague. With a pattern matching timeZone, year, month and day, the same silent fallback occurs, and because one of the dropped options was the time zone the output changes in two ways at once.

Measured: Aug 16, 2026 became 8/15/2026. The format changed, which is visible and would be noticed. The date also changed, which is the part that matters. The source pinned the formatter to UTC; the protected build fell back to the machine's local zone, and for a timestamp at midnight UTC that is the previous calendar day in every zone west of Greenwich.

This is a bug with a specific and unpleasant shape. It does not reproduce on a developer machine in a European time zone. It does not reproduce in a CI container that runs in UTC. It reproduces for users, in the afternoon, in the Americas, and it reports a date exactly one day early on invoices, statements and expiry notices.

The collator arm is quieter still. Dropping sensitivity and numeric changed the sort from Item2, item9, item10 to item10, Item2, item9: case-sensitive, lexicographic, and therefore ordering item10 before item9. The list is still sorted. It is sorted by the wrong rule, and nothing about the output announces that.

The parts and the methods

Two other columns round the picture out. formatToParts returns objects the platform constructs, with fields named type and value. Renaming those names does not change what the platform returns -- it changes what your code asks for. The measured output was undefined:undefined repeated once per part, which at least renders as visible nonsense rather than a plausible number.

The same pattern reached Intl.PluralRules, whose select call was fine but whose type: 'ordinal' option was not, so the ordinal categories one, two, few, other collapsed to one, other, other, other. Cardinal rules are the default; ordinal rules were what the dropped option asked for.

The method names fail loudly, which is the one merciful result here. A pattern matching format, compare and select produced an immediate TypeError on the first call and the process stopped. If you are going to break something, breaking it at the first line is better than formatting nine hundred invoices with the wrong currency.

And the control arm behaved: a pattern matching only the names the sample owns, its words array and its out accumulator, measured identical on both targets. Renaming is a consistent substitution, and when a name never leaves the file the substitution is invisible.

Why this one is worth checking by hand

Most of the compatibility hazards on this site announce themselves. A renamed promise method throws. A renamed wasm import fails to link. A renamed class method is a TypeError at the first call. The Intl options object is the opposite: the platform is specified to ignore properties it does not recognise, so a renamed key produces a working formatter that formats the wrong thing.

That makes it invisible to the two checks teams usually rely on. A smoke test that loads the page and clicks through passes, because nothing throws. A test suite that asserts on formatted output catches it immediately -- but only if you have such a test, and formatted currency strings are exactly the sort of thing test suites tend to avoid asserting on because they feel brittle.

The generalisation this series keeps arriving at holds again, and the Intl case is its clearest illustration: renaming is safe when a name is reached only by code the tool can see, and unsafe when the name is part of a contract with something outside the file. Here the outside reader is the internationalisation library built into the runtime, and the contract is a dictionary of well-known key names.

The mitigation is the same one this site recommends everywhere and it works here without qualification: anchor the member pattern to names your own application owns. An options dictionary handed to a platform constructor is not one of them.

How to check your own build

Three assertions cover the entire surface, and all three are cheap. Format one currency amount and compare the string. Format one date with an explicit timeZone and compare the string. Sort a three-item list with a configured collator and compare the order. If those match the unprotected build, your options objects survived.

The precise diagnostic, if one of them does not match, is resolvedOptions. Call it on the formatter in the protected build and log the whole object rather than reading fields off it. If the object contains real option names but your code reads undefined, the keys in your source were renamed. If the object contains defaults where you expected your settings, the dictionary you passed in was renamed.

It is worth running these assertions with the machine's time zone set to something other than UTC, because the date failure is invisible in UTC. Setting TZ to a western zone for one test run is enough to make an off-by-one-day fallback fail visibly rather than in production.

If you use member renaming and your application formats money, dates or sorted lists for users, this is the check to add first. It costs three assertions and it covers a failure mode that no amount of clicking through the application will surface.

Frequently asked questions

Does obfuscation break Intl formatting?

Not on its own. A currency formatter, a percent formatter, a UTC-pinned date formatter, a configured collator and ordinal plural rules all produced byte-identical output across five protection configurations, including both target versions and the string-transform profile. Locale tags and option values are string literals, and the string transforms do not change what a literal decodes to.

Why does my currency suddenly format without a currency symbol?

Because member renaming reached the keys of the options object. The emitted call becomes something like new Intl.NumberFormat('en-US',{_0x1:'currency',_0x2:'USD'}), and the platform ignores properties it does not recognise rather than throwing. It falls back to plain decimal formatting, so $1,234.50 is rendered as 1,234.5. Nothing is logged, because from the platform's point of view you asked for a default number format.

Can member renaming change what date my application displays?

Yes, and this is the sharpest measured case. A pattern matching timeZone, year, month and day dropped every option including the UTC pin, and Aug 16, 2026 was rendered as 8/15/2026 -- a different format and, for a midnight-UTC timestamp viewed from a western time zone, the previous calendar day. It does not reproduce on a machine running in UTC, which is why CI usually misses it.

Are Intl method names safe to rename?

No, but they fail loudly, which makes them the easier problem. A member pattern matching format, compare or select threw a TypeError on the first call and stopped the process. Compare that with the options object, where the same mistake produces a working formatter that quietly formats the wrong thing.

Does formatToParts still work after protection?

The call works; reading the result may not. The platform builds the parts array with fields named type and value, so if your member pattern renames those names your code asks the returned objects for properties they do not have and every part reads undefined. The array itself, its length and its ordering are unchanged.

What is the cheapest way to be sure my formatting is unchanged?

Assert on three strings: one formatted currency amount, one date formatted with an explicit timeZone, and the order of a three-item list sorted through a collator. Run that check with the machine's time zone set to something other than UTC so a dropped timeZone option fails visibly. If a check does fail, log the formatter's resolvedOptions in full, which distinguishes a renamed read from a renamed options dictionary.

Related reading