Stage: 1
Champion: Ben Allen @ben-allen
Author: Ben Allen @ben-allen
TC39 discussions:
In the real world, it is rare to have a number by itself. Numbers are more often measuring an amount of something, from the number of apples in a bowl to the amount of Euros in your bank account, and from the number of milliliters in a cup of water to the number of kWh consumed by an electric car per mile. When measuring a physical quantity, numbers also have a precision, or a number of significant digits.
Intl formatters have long been able to format amounts of things, but the quantity associated with the number is not carried along with the number into Intl APIs, which causes real-world bugs.
We propose creating a new object for representing amounts, for producing formatted string representations thereof, and for converting amounts between scales.
Common user needs that can be addressed by a robust API for measurements include, but are not limited to:
-
The need to keep track of the precision of measured values. A measurement value represented with a large number of significant figures can imply that the measurements themselves are more precise than the apparatus used to take the measurement can support.
-
The need to represent currency values. Often users will want to keep track of money values together with the currency in which those values are denominated.
-
The need to format measurements into string representations
-
The need to convert measurements from one scale to another
-
Related to both of the above, the need to localize measurements.
We propose creating a new Amount primordial containing an immutable numeric value, precision, and unit.
Amount will have the following read-only properties:
Note:
value(Number or BigInt or String): The numerical value of the amount. By default, the type of the value used in the constructor is retained. The value of an Amount constructed with precision options, or one that's the result of unit conversion, is always a numerical string.unit(String or not defined): The unit of measurement associated with the Amount's numerical value. An undefined value indicates "no unit supplied".
-
new Amount(value[, options]). Constructs an Amount with the numerical value ofvalueand optionaloptions, of which the following are supported (all being optional):unit(String): A unit identifier associated with the numerical value, which must not be an empty string.fractionDigits: the number of fractional digits the mathematical value should have (can be less than, equal to, or greater than the actual number of fractional digits that the underlying mathematical value has when rendered as a decimal digit string)significantDigits: the number of significant digits that the mathematical value should have (can be less than, equal to, or greater than the actual number of significant digits that the underlying mathematical value has when rendered as a decimal digit string)roundingMode: one of the seven supported Intl rounding modes. This option is used when thefractionDigitsandsignificantDigitsoptions are provided and rounding is necessary to ensure that the value really does have the specified number of fraction/significant digits.
Attempting to construct an Amount from a
valuethat is not a Number or BigInt or String will throw a TypeError. When constructing an Amount from a Stringvalue, its mathematical value is parsed using StringNumericLiteral or a RangeError is thrown. Thevalueproperty of a String-valued Amount is not necessarily equal to thevalueits constructor was called with, as it is always a StrDecimalLiteral, or"NaN".If either
fractionDigitsorsignificantDigitsis set, thevalueis rounded accordingly, and is stored as a String.
The object prototype would provide the following methods:
-
convertTo(options). This method returns an Amount in the scale indicated by theoptionsparameter, with the value of the new Amount being the value of the Amount it is called on converted to the new scale. Theoptionsobject supports the following properties:unit(String): An explicit conversion target unit identifierlocale(String or Array of Strings or undefined): The locale for which the preferred unit of the corresponding category is determined.usage(String): The use case for the Amount, such as"person"for a mass unit.- Optional properties with the same meanings as the corresponding
Intl.NumberFormat constructor digit options:
minimumFractionDigitsmaximumFractionDigitsminimumSignificantDigitsmaximumSignificantDigitsroundingModeroundingPriority
The
optionsmust contain at least one ofunit,locale, orusage. If theoptionscontains an explicitunitvalue, it must not containlocaleorusage. Iflocaleis set andusageis undefined, the"default"usage is assumed. Ifusageis set andlocaleis undefined, the default locale is assumed.The result of unit conversion will be rounded according to the digit options. By default, if no rounding options are set,
{ minimumFractionDigits: 0, maximumFractionDigits: 3}is used. If both fraction and significant digit options are set, the resulting behaviour is selected by theroundingPriority. The numerical value of the Amount resulting from unit conversion is stored as a String.Calling
convertTo()will throw an error if conversion is not supported for the Amount's unit (such as currency units), or if the resolved conversion target is not valid for the Amount's unit (such as attempting to convert a mass unit into a length unit). -
toString(): A string representation of the Amount. Returns a digit string together with the unit in square brackets (e.g.,"1.23[kg]) if the Amount does have a unit; otherwise, the digit string is suffixed with empty square brackets[](e.g.,"42[]"). -
toLocaleString(locale[, options]): Return a formatted string representation appropriate to the locale (e.g.,"1,23 kg"in a locale that uses a comma as a fraction separator). The options are a subset of the Intl.NumberFormat constructor options.
Unit conversion is supported for some units, the data for which is provided by the CLDR in the its file
common/supplemental/units.xml.
This file also provides the data for per-usage and per-locale unit preferences.
For each unit type, the data given in CLDR defines
a multiplication factor (and an offset for temperature untis)
for converting from a source unit to the unit type's base unit.
For example, the base unit for length is meter, and the conversion from foot to meter is given as 0.3048,
while the conversion from inch to meter is given as 0.3048/12.
Unit conversions with Amount work by first converting the source unit to the base unit, and then to the target unit. Each of these operations is done with Number operations. For example, to convert 1.75 feet to inches, the following mathematical operations are performed internally:
1.75 * 0.3048 / (0.3048 / 12) = 20.999999999999996Rounding is applied only to the final result, according to the digit options
set in the conversion method's options.
The precision of the source Amount is not retained,
and the precision of the result is capped by the precision of Number.
The locale and usage values that may have been used in the conversion are not retained,
but the resulting Amount will of course have an appropriate unit set.
For example:
let feet = new Amount(1.75, { unit: "foot" });
feet.convertTo({ unit: "inch" }); // 21 inches
feet.convertTo({ locale: "fr", usage: "person", maximumSignificantDigits: 3 }); // 53.3 cmFirst, an Amount with only a value:
let a = new Amount(123.456, { fractionDigits: 4 });
a.value; // "123.4560"
typeof a.value; // "string"
a.toString(); // "123.4560[]"
a.toLocaleString("fr"); // "123,4560"Here's an example with units:
let a = new Amount(42.7, { unit: "kg" });
a.value; // 42.7
typeof a.value; // "number"
a.toString(); // "42.7[kg]"
a.toLocaleString("fr"); // "42,7 kg"An Amount significantly improves the ergonomics of number formatting and encourages better design patterns for i18n correctness, by correctly separating the data model, user locale, and developer settings.
Without Amount, the purpose of each argument is mixed together:
let numberOfKilograms = 42.7;
let locale = "zh-CN";
let localizedString = new Intl.NumberFormat(locale, {
minimumSignificantDigits: 4,
style: "unit",
unit: "kilogram",
unitDisplay: "long",
})
.format(numberOfKilograms);
console.log(localizedString); // "42.70千克"With Amount, it is more ergonomic and therefore easier to do the right thing:
// Data model: the thing being formatted
let amt = new Amount("42.7", { unit: "kilogram", significantDigits: 4 });
// User locale: how to localize
let locale = "zh-CN";
// Developer options: how much space is available, for example.
let options = { unitDisplay: "long" };
// Put it all together:
let localizedString = amt.toLocaleString(locale, options);
console.log(localizedString); // "42.70千克"The Amount type can also be interpolated into MessageFormat implementations, starting in userland and potentially in the standard library in the future.
A common footgun in i18n is the need to set the same precision on both Intl.PluralRules and Intl.NumberFormat. For example:
// This code is buggy! Do you see why?
let locale = "en-US";
let numberOfStars = 1;
let numberString = new Intl.NumberFormat(locale, { minimumFractionDigits: 1 }).format(numberOfStars);
switch (new Intl.PluralRules(locale).select(numberOfStars)) {
case "one":
console.log(`The rating is ${numberString} star`);
break;
default:
console.log(`The rating is ${numberString} stars`);
break;
}This code outputs: "The rating is 1.0 star", which is grammatically incorrect even in English, which has relatively simple rules. The problem is exaggerated in languages with additional plural forms and/or other inflections!
Using Amount makes the code work the way it should, and makes it easier to follow the logical flow:
let locale = "en-US";
let stars = new Amount(1, { fractionDigits: 1 });
let numberString = stars.toLocaleString(locale);
// Note: This uses a potential toLocalePlural method.
switch (stars.toLocalePlural(locale)) {
case "one":
console.log(`The rating is ${numberString} star`);
break;
default:
console.log(`The rating is ${numberString} stars`);
break;
}If the given precision is less than that of the input value, rounding will occur. (Upgrading just adds trailing zeroes.)
let a = new Amount("123.456", { significantDigits: 5 });
a.value; // "123.46"By default, we use the round-ties-to-even rounding mode, which is used by IEEE 754 standard, and thus by Number and Decimal. One can specify a rounding mode:
let b = new Amount("123.456", { significantDigits: 5, roundingMode: "truncate" });
b.value; // "123.45"A core piece of functionality for the proposal is to support units (mile, kilogram, etc.) as well as currency (EUR, USD, etc.). An Amount need not have a unit/currency, and if it does, it has one or the other (not both). Example:
let a = new Amount(123.456, { unit: "kg" }); // 123.456 kilograms
let b = new Amount("42.55", { unit: "EUR" }); // 42.55 EurosNote that, currently, no meaning is specified within Amount for units, except for what is supported for unit conversion.
You can use "XYZ" or "keelogramz" as a unit.
Calling toLocaleString() on an Amount with a unit not supported by Intl.NumberFormat will throw an Error.
Unit identifiers consisting of three upper-case ASCII letters will be formatted with style: 'currency',
while all other units will be formatted with style: 'unit'.
Amount is intended to be a small, straightforwardly implementable kernel of functionality for JavaScript programmers that could perhaps be expanded upon in a follow-on proposal if data warrants. Some features that one might imagine belonging to Amount are natural and understandable, but are currently out-of scope. Here are the features:
Below is a list of mathematical operations that one could consider supporting. However, to avoid confusion and ambiguity about the meaning of propagating precision in arithmetic operations, we do not intend to support mathematical operations. A natural source of data would be the CLDR data for both our unit names and the conversion constants are as in CLDR. One could conceive of operations such as:
- raising an Amount to an exponent
- multiply/divide an Amount by a scalar
- Add/subtract two Amounts of the same dimension
- multiply/divide an Amount by another Amount
- Convert between scales (e.g., convert from grams to kilograms)
could be imagined, but are out-of-scope in this proposal. This proposal focuses on the numeric core that future proposals can build on.
Some units can derive other units, such as square meters and cubic yards (to mention only a couple!). Support for such units is currently out-of-scope for this proposal.
Some units can be combined. In the US, it is common to express the heights of people in terms of feet and inches, rather than a non-integer number of feet or a "large" number of inches. For instance, one would say commonly express a height of 71 inches as "5 feet 11 inches" rather than "71 inches" or "5.92 feet". Thus, one would naturally want to support "foot-and-inch" as a compound unit, derivable from a measurement in terms of feet or inches. Likewise, combining units to express, say, velocity (miles per hour) or density (grams per cubic centimeter) also falls under this umbrella. Since this is closely related to unit conversion, we prefer to see this functionality in Smart Units.
This type exists primarily for interop with existing native language features, like Intl, and between libraries.
Although Intl is what drives some of the champions to pursue this proposal, the use cases are not limited to Intl. The Amount type is a generally-useful abstraction on top of a numeric type with some non-Intl functionality such as serialization and library interop. Optimal i18n on the Web Platform depends on Amount being a widely accepted and used type, not something only for developers who are already using Intl. It is not unlike how Temporal types earning widespread adoption improves localization of datetimes on the Web.
Some delegates unconvinced by the non-Intl use cases have suggested that Intl.NumberFormat.prototype.format can read fields from its argument the same as if it were passed a proper Amount object, which we call a "protocol" based approach.
The primordial assists with discoverability and adoption. If it is just a protocol supported by Intl.NumberFormat, then the usage that would get would be significantly lower than if an Amount actually existed as a thing that developers could find and use and benefit from. The primordial also allows fast-paths in engine APIs that accept it as an argument.
The protocol should likely coexist, as it enables polyfills and cross-membrane code.
Why represent precision as number of significant digits instead of something else like margin of error?
Existing ECMA-262 and ECMA-402 APIs deal with precision in terms of significant digits: for example, Number.prototype.toPrecision and minimumSignificantDigits in Intl.NumberFormat and Intl.PluralRules. We do not wish to innovate in this area. Further, CLDR does not provide data for formatting of precision any other way, and we are unaware of a feature request for it.
- Smart Units (mentioned several times as a natural follow-on proposal to this one)
- Decimal for exact decimal arithmetic
- Keep trailing zeroes to ensure that when Intl handles digit strings, it doesn't automatically strip trailing zeroes (e.g., silently normalize "1.20" to "1.2").
A polyfill is available for testing. Since this proposal is still at stage 1, expect breaking changes; in general, it is not suitable for production use.