Properly formating a number
Published on 6 May 2020 • 6 min

Cette page est également disponible en français.

Welcome to the third article of our daily series: “19 nuggets of vanilla JS.” This time we’ll talk about formating numbers, and see that we have amazing native capabilities!

The series of 19

Check out surrounding posts from the series:

  1. Efficiently deduplicating an array
  2. Efficiently extracting a substring
  3. Properly formatting a number (this post)
  4. Array#splice
  5. Strings and Unicode
  6. …and beyond! (fear not, all 19 are scheduled already)…

What do you mean, “formating”?

This can mean any number of things. I suggest we consider three major types of needs here:

  • A fixed number of fractional digits (technical display)
  • A change of numeric basis, or radix (same)
  • A human representation anchored in a linguistic context (locale). It could be a plain number, a currency value, a percentage, disk usage… there’s really no shortage of use cases.

For the first two use cases, we’ve had solutions forever, but considering nobody reads the docs and many people have a hard time understanding that in JavaScript, even primitive number values can act as objects (“autoboxing”), these solutions tend to go unnoticed.

The hidden old-timers

The Number type comes with a number (ah ah) of instance methods, two of which are super useful here and often needlessly re-implemented.

Careful! Unlike many more permissive grammars such as Ruby’s, JavaScript’s does not allow direct indexing through the dot (.) operator on an integer literal: any dot after such as literal will be regarded as the decimal separator that follows the integer part:

3.toString()    // => SyntaxError: Identifier directly after number
(3).toString() // => '3'
3.14.toString() // => '3.14' (the role of the 2nd dot is unambiguous)
3..toString() // => '3', but will instantly set your house on fire

In practice this is hardly an issue: when we have the literal value, we might as well have its literal formated representation instead of computing it! In general, the number is referenced through an identifier, mooting the point:

const n = 3
n.toString() // => '3'

toFixed()

We often need to format a numeric display using fixed fractional, for instance for alignment purposes. As many people have no idea this can be done natively, we often stumble on shamble implementations such as this one:

// #idgara #whistling #paidbythehour
function toFixed(n, fractionalDigits) {
const factor = 10 ** fractionalDigits
// Pre ES2016: Math.pow(10, fractionalDigits)
return Math.round(n * factor) / factor
}
toFixed(Math.PI, 4) // => '3.1416'

There’s been a native solution ever since ES3 (1999)!

// #duh!
Math.PI.toFixed(4) // => '3.1416'

By the way, do not mistake this for toPrecision(…), that is about the total number of significant digits (in the integer plus fractional parts).

Cool beans, but this still results in a technical literal, with no locale-aware formating (thousands grouping, decimal separator)… Now, if this is enough, then cool, but sometimes you’ll need more (we’ll get to that shortly).

toString(radix)

Another common need in a technical context is to choose a display radix. You know: octal, hexadecimal, binary… This is likely geared towards a technical format instead of a display to end-users, but who knows.

Here again, we find a ton of hand-rolled solutions online, despite a native solution for radix 2 to 36 (yes, 36) being available since JS 1.1 (1.1, dang! In 1996, my friend! Were you even born?!). Like all other objects, Number instances feature a toString() instance method; but unlike most objects, it accepts an argument: an optional radix, which defaults to 10.

const FORTY_TWO = 42
FORTY_TWO.toString(2) // => '101010'
FORTY_TWO.toString(8) // => '52'
FORTY_TWO.toString(16) // => '2a'
FORTY_TWO.toString(36) // => '16'

Tadaaaa!

“I have the power!” — Intl

(Bonus points if you get that reference.)

When we need “cleaner” formating, geared toward Real People™ with a linguistic context (which is part of display localization, or L10n), we long had to break out the big guns with Moment.js or other modules with a big fat localization corpus (around 1MB, quite the bundle!).

Nowadays we can lighten things up with solutions such as Format.JS, which is nice but is really just sugar-coating on top of a native API JavaScript engines have provided for quite a while now: the Intl namespace.

ECMA-402

This part of JavaScript’s “standard library” has its own standard: ECMA-402, which is driven by the same technical committee (TC39) as ECMA-262, the standard for JavaScript itself.

The idea is to let our JS code access the enormous corpus of formating rules, which can get pretty intricate from one language to the next, related to numbers and dates. There are a huge number of cases, variations, fine print… It is all known as the CLDR (Common Locale Data Repository), which is part of the ICU (International Components for Unicode), and can usually be found among the libraries of our OS, maintained multiple times a year.

In our situation, we’re mostly interested by the Intl.NumberFormat class, that lets us create extremely detailed and versatile numeric formaters.

new Intl.NumberFormat(…).format(n) vs. n.toLocaleString(…)

With ES5.1 (2010), the legacy toLocaleString() instance method on Number got expanded. It used to not accept any argument and just return the default format for the active locale; it then started accepting all the options of new Intl.NumberFormat(…).

If we just need a one-shot format, this constitutes a neat shortcut:

// Long form
new Intl.NumberFormat(locale, options).format(n)
// Short form
n.toLocaleString(locale, options)

But… if we reuse the same format over and over (e.g. within a long loop, or in response to a frequent event such as mousemove), we’ll be better off instanciating the formatter only once, and reusing it from then on:

// Heavy-handed
const texts = largeArray.map((n) => n.toLocaleString(locale, options))
// Better
const formater = new Intl.NumberFormat(locale, options)
const texts = largeArray.map(formater.format, formater)

In the remainder of this post, I’ll mostly use the toLocaleString(…) shorthand for the sake of brevity, but keep this performance aspect in mind!

Formating the number itself

Let’s start with how to properly format the number itself. It turns out to be quite variable. There are group delimiters (generally by blocks of three: thousands, millions, etc.), the decimal separator, the generally-accepted amount of digits after that separator… This all varies on a per-locale basis.

Before we had Intl, doing groups on the integer part required pretty hacky code, check this out:

const REGEX_GROUPS = /(\d)(?=(\d\d\d)+(?!\d))/g
function useGrouping(int, delimiter = ',') {
return int.toString().replace(REGEX_GROUPS, `$1${delimiter}`)
}

useGrouping(3141596) // => '3,141,596'

There’s your regex with positive and negative lookaheads! (Cool trick, tho.)

As you might expect, we don’t need any of that with Intl:

new Intl.NumberFormat('fr-FR').format(3141596) // => '3 141 596'

Currency values

It’s not always immediately apparent, but formating currencies raises many questions, and every locale has its own answers, that can vary even further by context:

  • Is the currency leading or trailing the number?
  • Is there a currency separator? If so, which one?
  • Should the currency be represented as a symbol, a code or its full name?

Formating a unit number isn’t that easy…

Phew… It turns out to be pretty sweet:

const cash = Math.PI * 1000
cash.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })
// => '3 141,59 €'
cash.toLocaleString('en-US', {
style: 'currency', currency: 'EUR', currencyDisplay: 'code'
})
// => 'EUR 3,141.59'
cash.toLocaleString('fr-FR', {
style: 'currency', currency: 'EUR', currencyDisplay: 'name'
})
// => '3 141,59 euros'

cash.toLocaleString('en-US', { style: 'currency', currency: 'USD' })
// => '$3,141.59'
cash.toLocaleString('en-US', {
style: 'currency', currency: 'USD', currencyDisplay: 'name'
})
// => '3,141.59 US dollars' -- note how the currency is now trailing

cash.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }))
// => '3.141,59 €'

We deal here with three relevant options:

  • style becomes 'currency' (simply specifying the currency option doesn’t impact the style)
  • currency provides the ISO 4217 code for the currency. This option is mandatory the moment you use the 'currency' style: there will be no default value for the used locale, as we don’t necessarily talk about the currency for that locale, which might actually have multiple active currencies.
  • currencyDisplay can be either 'symbol' (default), 'code' (the ISO 4217 code) or 'name', which uses the currency’s full name in the used locale. This latest mode can alter the displayed position if need be.

Unit galore

Another handy use case for NumberFormat is related to the 'unit' value for the style option. It lets us assign a given unit to our display, which will use the correct text for that unit, in the specified locale. We can get a full unit text or its abbreviated form, and even go for a “narrow” mode which sometimes removes the unit separator, all thanks to the unitDisplay option.

We can work with numerous allowed units, plus any ratio combination using the -per- infix. We could go with a classic 'kilometer-per-hour' or go wild with 'celsius-per-minute' when showing how fast our bath is cooling down 😉

const n = 2.3456
n.toLocaleString('fr-FR', { style: 'unit', unit: 'kilometer-per-hour' })
// => '2,346 km/h'
n.toLocaleString('fr-FR', {
style: 'unit',
unit: 'kilometer-per-hour',
unitDisplay: 'narrow',
})
// => '2,346km/h'
n.toLocaleString('es-MX', {
style: 'unit',
unit: 'kilometer-per-hour',
unitDisplay: 'long',
})
// => '2,346 kilómetros por hora'

n.toLocaleString('de-DE', { style: 'unit', unit: 'celsius-per-minute' })
// => '2,346 °C/min'

As a final treat, there is a more recent option called notation, that lets us go with specific formats for the numeric part:

  • 'scientific' is baed on powers of 10, the “exponent” notation;
  • 'engineering' restricts that to powers of 10 that are multiple of 3, so geared towards orders of magnitude;
  • 'standard' is the default, which we used so far; finally there’s my little darling:
  • 'compact' represents the numeric value in a more “concise” way (which is liable to adjusting the amount of fractional numbers in an adaptive way, for instance).

This is adorable:

const ONE_MEGABYTE = 1024 * 1024

function toMegabytes(bytes) {
return (bytes / ONE_MEGABYTE).toLocaleString('fr-FR', {
style: 'unit',
unit: 'megabyte',
notation: 'compact',
})
}
toMegabytes(3541596) // => '3,4 Mo'
toMegabytes(4238394) // => '4 Mo'

Compatibility

Hang on, this is all so much win! But “when can I use it?” you say? Turns out, almost anywhere 😎.

Browsers

In IE-land, that’s gonna be IE11, period. In other browsers, it’s been around forever (Firefox 29, Chrome 24, Safari 10…), so chill.

If you absolutely need IE pre-11, there is, as expected, a polyfill, which is hefty, so I would recommend injecting it through Polyfill.io instead (they tell you how).

Node.js

Historically, Node wasn’t compiled with a full embedded CLDR, so it had precious few locales available. You could however launch it with explicit OS locations for it, so it could use it.

For instance, assuming your OS’ ICU matched your Node’s (Node 10+ = ICU 6, Node 8 had ICU 5.9, etc.), on OSX for instance that would look like the following:

# Minimalistic embedded ICU
$ node -pe 'Math.PI.toLocaleString("fr-FR")'
3.142

# CLI argument
$ node --icu-data-dir=/usr/share/icu -pe 'Math.PI.toLocaleString("fr-FR")'
3,142

# Environment variable
$ NODE_ICU_DATA=/usr/share/icu node -pe 'Math.PI.toLocaleString("fr-FR")'
3,142

The full-icu npm module was a considerable simplification here, as it detected your compiled Node’s ICU version, figured out what was available, and dynamically downloaded the missing data in an automagic way.

Ever since Node 13, you’re all set: Node is now precompiled with full ICU, including the CLDR. So if you’re on Node 13, 14 (LTS) or later, you’re good to go!