Software localization
Roll Your Own JavaScript i18n Library with TypeScript – Part 2

In part 1 of this series, we observed several reasons why we might want to roll our own JavaScript i18n library. We also took the first steps into building such a library. Let's quickly revisit this.
Writing our own i18n library offers us two crucial benefits:
- lean code that gives us exactly what our app needs, saving kilobytes downloaded and code parsing time in the browser;
- a good amount of insight into how i18n libraries work, which deepens our understanding of i18n as developers.
So what would an in-house i18n library have to do?
Basic Functionality (refer to part 1)
- Locale detection and resolution
- Defining supported locales
- Translation file loading for the resolved locale
- Manually setting/forcing the locale
- Displaying translations (retrieval from the currently loaded translation file)
Interpolation (you're here)
- Handling dynamic arguments in our translation strings
- Handling singular/plural forms
- Formatting dates
- Formatting currency
At this point, we have the library's basic functionality done, and we're ready to tackle interpolation. If you missed the first part, I urge you to check it out, as the code in this second part builds on top of what we put in place there. You can find the code that goes along with the first part on GitHub. Let's very briefly go over what this code does.
Note: The code for this part, part 2, is on GitHub as well.
Our JavaScript i18n Library so Far
We've called our library Gaia. Here's a look at Gaia's API as it stands right now.
Note: We're using TypeScript to build our library. We go over our reasoning for using TypeScript in part 1. If you're a fan of TypeScript, we got you covered. If you just want plain old JavaScript, feel free to ignore the type information in this article's code. TypeScript, after all, is effectively JavaScript with strong typing.
/** * Initialize the library. * * @param options.supportedLocales - locales the app supports * @param options.locale - initial locale, defaults to the browser locale * @param options.fallbackLocale - locale to use if `options.locale` is not * provided and we cannot determine the browser locale * @returns Promise<string> - resolves with the determined locale after loading the locale's translation file */ gaia.init(options: { supportedLocales: string[], locale?: string, fallbackLocale?: string, }): Promise<string> /** * Normalized array of supported locales * e.g. `['en-us', 'ar', 'fr']`. */ gaia.supportedLocales: ReadonlyArray<string> /** * Check if the given locale is supported. * * @param locale - the locale to check */ gaia.isSupported(locale: string): boolean /** * The current locale e.g. 'en-us' */ gaia.locale: string /** * Set the current locale and load its translations. * * @param locale - the locale to set */ gaia.setLocale(locale: string): Promise<void> /** * Retrieve a string translated to the current locale. * * @param key - index of translation to retrieve * @returns string */ gaia.t(key: string): string
Our library will assume that translation files for supported locales are provided as JSON that is available publicly at the URI /lang/<locale>.json
and are simple key-value string pairs.
/lang/fr.json
{ "title": "Votre panier", "lead": "Bonsoir, Adam. Voici ce qui se trouve actuellement...", // ... }
These translations are used when a localized string is retrieved by the t
function.
This allows us to do something like this in our app:
const supportedLocales = { 'ar-eg': 'عربي', fr: 'Français', en: 'English' }; // on load gaia .init({ supportedLocales, locale: 'fr' }) .then((locale) => { // translations are ready to use, proceed to rendering }); // on render / display h1Element.innerHTML = gaia.t('title'); // renders title in French
Simple Interpolation
This works fine for static strings, but we'll need to build on our solution for dynamic values. Let's assume that our user's name is dynamic and that we don't want to hard-code it in our translation files. We could provide it as an argument to our t
function.
pElement.innerHTML = t('lead', { name: 'Adam' });
In our translation files, we can specify a placeholder for this argument.
{ "lead": "Bonsoir, {name}. Voici ce qui se trouve..." }
What the user would see, of course, would be: "Bonsoir, Adam. Voici ce qui se trouve..."
So we'll accept a plain old JavaScript object for our arguments, and use the {placeholder}
syntax in our language files. Let's implement the logic that makes this work.
src/gaia/gaia.ts
//... import { interpolateSimple } from './lib/interpolate'; // ... import { Translations, TranslationReplacements } from './lib/types'; // ... const gaia = { // ... t(key: string, replacements?: TranslationReplacements): string { const translated = _translations[key] || key; if (replacements === undefined || Object.keys(replacements).length === 0) { return translated; } return interpolateSimple(translated, replacements); }, }; // ...
src/gaia/lib/types.ts
export interface StringToStringMap { [key: string]: string } export interface Translations { [key: string]: string } export interface TranslationReplacements { [key: string]: string }
Note: You can get all of this article's code on GitHub.
Note: Recall that
_translations
is a string to string map that contains the key-value pairs from the currently loaded translation file. We go over this in part 1.
We accept an optional replacements
parameter in our t
function. If we are given a replacements
map, we run our interpolation logic on it via a utility function, interpolateSimple
. We will handle other kinds of interpolations — namely plurals, dates, and currency — in the future; so we establish a naming convention for our interpolation helpers. Let's take a look at our simple interpolator, which swaps given replacements for placeholders.
src/gaia/lib/interpolate.ts
import { TranslationReplacements } from "./types"; export function interpolateSimple( source: string, replacements: TranslationReplacements, ): string { const placeholderRegexp = getPlaceholderRegexp(); let interpolated = source; let currentMatch = placeholderRegexp.exec(source); while (currentMatch !== null) { const [placeholder, replacementKey] = currentMatch; const replacement = replacements[replacementKey.trim()]; if (replacement !== undefined) { interpolated = interpolated.replace(placeholder, replacement.toString()); } currentMatch = placeholderRegexp.exec(source); } return interpolated; } let _placeholderRegExp: RegExp; function getPlaceholderRegexp(): RegExp { if (_placeholderRegExp === undefined) { _placeholderRegExp = new RegExp('\{\s*([^{}]+?)\s*\}', 'g'); } return _placeholderRegExp; }
To identify placeholders, we use a regular expression, which we retrieve via a helper getPlaceholderRegexp
function. getPlaceholderRegexp
retrieves a cached RegExp
object that matches {placeholder}
strings globally in the source translation string. A global g
match allows us to have multiple placeholders in a single string.
We use the built-in RegExp.prototype.exec
method to iterate over all the placeholder matches we find. For each match, e.g., {firstName}
, we're given the full ({firstName}
) string, as well as just the inner (firstName
) text as a capturing group. Note that the capturing group drops the {}
braces. We store both the full match and the captured group in variables called placeholder
and replacementKey
, respectively. We then check to see if the replacementKey
(firstName
) has a value in the replacements
map and swap the value in if it does. Once we've run through all matching placeholders, we return the string with all the replacements we've made.
So if interpolateSimple
is given, the source string 'Hello, {firstName} {lastName}, welcome to {app}.'
, and the replacement map { firstName: 'Eve', lastName: 'Adam' }
, it will return 'Hello Eve Adam, welcome to {app}'
. Notice that we leave placeholders that don't have replacements as they are. This serves to indicate to the developer any missing replacements.
Handling Plurals
We now have simple interpolation working and we can swap dynamic values into our translation strings. But what if we had a situation like the following?
// in view pElement.innerHTML = t('item_count', { count: 3 }); // in translation file "item_count": "Your cart has {count} items."
The above wouldn't work too well if the user had one item in her cart. We obviously need a better way to handle plurals. Different languages have different numbers of plural forms. While English has three forms (zero, one, and multiple), Arabic has five. To account for all these plural variations, we can design a flexible format for defining plurals in our language files.
public/lang/en.json
{ // ... "item_count": { "plural": { "0": "No items", "1": "{count} item", "2+": "{count} items" } }, // ...
public/lang/ar-eg.json
{ // ... "item_count": { "plural": { "0": "لا يوجد قطع", "1": "قطعة {count}", "2": "قطعتان", "3-10": "{count} قطع", "11+": "{count} قطعة" } }, //...
We can make things clear by differentiating simple strings from plural definitions in our translation files. A plural definition can be an object with one property, plural
. This property simply defines each plural form as an exact value, like "2"
; a limited range, like "3-10"
; or an unlimited range with a lower bound, like "11+"
. This way we can be very flexible, and plural translations can follow whatever rules their respective locales dictate. English can use its three forms, for example, and Arabic can use its five forms.
Arguments and count
We'll also account for simple interpolation within a plural form, so we can have a plural form be: "2+": "{username} has {count} items."
. Having a dynamic value like {username}
in a plural is a common enough case that we should cover it. It also allows us to display a dynamic count
argument in our plural forms. Note that the count
argument is special for plurals since it's used to resolve the form we'll return when we interpolate. A given count
of 7
would resolve to the 3-10
form, for example.
Ok, let's get this working...
src/gaia/gaia.ts
// ... t(key: string, replacements?: TranslationReplacements): string { const translated = _translations[key] || key; if (replacements === undefined || Object.keys(replacements).length === 0) { return translated as string; } if (typeof translated === 'object') { const keys = Object.keys(translated); if (keys.indexOf('plural') !== -1) { const definition = (translated as PluralTranslation).plural; return interpolatePlural(definition, replacements) || key; } } else if (typeof translated === 'string') { return interpolateSimple(translated, replacements); } return key; }, // ...
src/gaia/lib/types.ts
export interface StringToStringMap { [key: string]: string } export interface PluralTranslation { plural: StringToStringMap } export interface Translations { [key: string]: string | PluralTranslation } export interface TranslationReplacements { [key: string]: string | number }
Since a translation string can now be a string or an object, we check for this in our t
function. If the string is an object, we further check if it has a plural
key, and we process it as a plural via our new helper, interpolatePlural
, if it does. Otherwise, we effectively proceed as we did before, performing simple interpolation on the string. Let's take a look at the main workhorse here, interpolatePlural
.
src/gaia/lib/interpolate.ts
// ... export function interpolatePlural( definition: StringToStringMap, replacements: TranslationReplacements, ): string | undefined { const count: number = replacements.count as number; const countForms = Object.keys(definition); let match: string | undefined; for (let i = 0; i < countForms.length; i += 1) { const form = countForms[i]; // exact match if (parseInt(form, 10) === count) { match = form; break; } // range match e.g. "3-11" if (form.includes('-')) { const [min, max] = form.split('-').map(n => parseInt(n, 10)); if (min <= count && count <= max) { match = form; break; } } // greater than or equal to match e.g. "2+" if (form.includes('+') && count >= parseInt(form, 10)) { match = form; break; } } if (match !== undefined) { return interpolateSimple(definition[match], replacements); } } // ...
In interpolatePlural
we assume there's a count
number argument in our given replacements. We then iterate over the plural definition
, which is the object containing key-value pairs like "2": "{count} items"
. For each of these pairs, we check whether our count
has an exact match, a bounded range match, or an unlimited range match. If we have an exact match, we're done. If we match one of the two range matches, we further check if we're within the given range. If we are, we're good!
If our search results with a match, we need to account for further arguments within the translation string e.g., "{count} items"
would probably need {count}
replaced. So we pass our match to interpolateSimple
to do its argument-swapping thing.
OK, now we can have plurals in our translation files. Let's take a look at dates next.
Handling Dates
Of course, dates are often dynamic data and we may well want to interpolate date values in our translation strings. We could do something like the following.
// in our view pElement.innerHTML = t('updated', { updatedAt: new Date('January 1 2019') });
To make this work in our translation files we would need to designate date values and provide a way to format them. The simplest way would be to lean on the Intl.DateTimeFormat constructor built into modern browsers. To let translators tap into this API in their language files we could specify dates as follows.
Note
Intl.DateTimeFormat
is not supported in Android webviews. You may want to look at a polyfill if you want to support Android webviews. The Andy Earnshaw polyfill is apparently quite robust, although I haven't tried it myself.
public/lang/en.json
// ... "updated": { "format": "Updated {updatedAt:date}", "updatedAt": { "weekday": "short", "year": "numeric", "month": "long", "day": "numeric" } }, // ...
We use an object to differentiate strings that need date interpolation from simple strings. A "format"
key differentiates dates from plurals, and a ":date"
suffix in our argument name indicates that the argument should be treated as a date. This is because we'll introduce currency to "format"
strings a bit later, and we don't want to confuse things when we do. The Intl.DateTimeFormat
constructor built into modern browsers takes an options object parameter that specifies the required date format for printing. We can simply accept these options in a map that corresponds to the date argument in question, like the "updatedAt"
map in the above example.
Ok, let' get this working.
src/gaia/gaia.t
// ... const gaia = { // ... t(key: string, replacements?: TranslationReplacements): string { const translated = _translations[key] || key; if (replacements === undefined || Object.keys(replacements).length === 0) { return translated as string; } if (typeof translated === 'object') { const keys = Object.keys(translated); if (keys.indexOf('plural') !== -1) { const definition = (translated as PluralTranslation).plural; return interpolatePlural(definition, replacements) || key; } else if (keys.indexOf('format') !== -1) { return interpolateFormatted( translated as FormattedTranslation, replacements, _locale, ); } } else if (typeof translated === 'string') { return interpolateSimple(translated, replacements); } return key; }, // ...
/src/gaia/lib/types.ts
export interface StringToStringMap { [key: string]: string } export interface PluralTranslation { plural: StringToStringMap } export interface FormattedTranslation { format: string [key: string]: string | DateFormatOptions } export interface DateFormatOptions { [key: string]: string | boolean } export interface Translations { [key: string]: string | PluralTranslation | FormattedTranslation } export interface TranslationReplacements { [key: string]: string | number | Date }
Note: If you're following along with the types and are wondering why the
DateFormatOptions
interface defines aboolean
type;Intl.DateFormat
formatting options have anhour12
property which can betrue
orfalse
. Check out the MDN docs for the constructor parameters for more details.
We add a check for the format
key in our t
function to check for our formatting option in translation files. And, as per our convention, we offload the main work of date interpolation to an interpolateFormatted
function. Since we will eventually use the Intl.DateTimeFormat
to format our dates, we make sure to pass our current locale to the interpolation function, since the built-in Intl
constructor will need an explicit locale to do its work.
src/gaia/lib/interpolate.ts
// ... export function interpolateFormatted( definition: FormattedTranslation, replacements: TranslationReplacements, locale: string, ): string { const placeholderRegexp = getPlaceholderRegexp(); const source = definition.format; let interpolated = definition.format; let currentMatch = placeholderRegexp.exec(source); while (currentMatch !== null) { const [placeholder, compositeKey] = currentMatch; const compositeKeyParts = compositeKey.trim().split(':'); if (compositeKeyParts.length === 2) { const [replacementKey, type] = compositeKeyParts; if (type === 'date') { const options = definition[replacementKey] as DateFormatOptions; const date = replacements[replacementKey] as Date; if (date !== undefined && options !== undefined) { const format = new Intl.DateTimeFormat(locale, options) .format(date); interpolated = interpolated.replace(placeholder, format); } } } currentMatch = placeholderRegexp.exec(source); } return interpolateSimple(interpolated, replacements); } // ...
interpolateFormatted
retains the same basic structure as its plural counterpart. As before, we check for {placeholder}
occurrences in the source string, and we iterate over them. This time, however, our placeholder
keys are different: they're composites that look like name:type
e.g., updatedAt:date
. So we break each key apart and check its type part. If it's a date, we grab its corresponding value from the replacements
map supplied and assume the value to be a Date
. We then delegate to Intl.DateTimeFormat
to create a formater and call it immediately to format the date string as per our given options
.
So now, when we supply the following options in our English translation file, for example, we get the subsequent format.
public/lang/en.json
// ... "updated": { "format": "Updated {updatedAt:date}", "updatedAt": { "weekday": "short", "year": "numeric", "month": "long", "day": "numeric" } }, // ...
// in our view pElement.innerHTML = t('updated', { updatedAt: new Date('January 1 2019 12:30'), }); // renders "Updated Tue, January 1, 2019"
The cool thing is we don't have to specify anything extra for our other locales. Our French file can look like this:
// ... "updated": { "format": "Mis à jour le {updatedAt:date}", "updatedAt": { "weekday": "short", "year": "numeric", "month": "long", "day": "numeric" } }, // ...
And, when switched to French, our view would render "Mis à jour le mar. 1 janvier 2019". That's because the Intl.DateTimeFormat
formatter takes care of things like localized month names for us.
Handling Currency
OK, now that we have dates working, let’s get to our last type of interpolation: currency. This will be very similar to our date interpolation. In fact, we’ll use the Intl.NumberFormat constructor built into modern browsers.
We can define currency in our apps like the following.
// in our view divElement.innerHTML = t('itemPrice', { price: 2.99 }); // in our language file { "itemPrice": { "format": "Item costs {price:currency}", "price": "USD" } }
We’re using the special "format"
key to designate special formatting just like we did with our dates. This time, however, we use a ":currency"
suffix instead of ":date"
. We also provide the argument ("price"
) value as an ISO 4127 currency code, e.g., “EUR”
for Euro. Intl.NumberFormat
accepts these codes for its currency option, and we’ll pass that code directly to the NumberFormat
constructor when we implement currency interpolation. Let’s get cooking.
Our t
function won’t have to change, since we’re already handling format
ted interpolation. We just need to handle currencies in our interpolateFormatted
function.
src/gaia/lib/interpolate.ts
export function interpolateFormatted( definition: FormattedTranslation, replacements: TranslationReplacements, locale: string, ): string { const placeholderRegexp = getPlaceholderRegexp(); const source = definition.format; let interpolated = definition.format; let currentMatch = placeholderRegexp.exec(source); while (currentMatch !== null) { const [placeholder, compositeKey] = currentMatch; const compositeKeyParts = compositeKey.trim().split(':'); if (compositeKeyParts.length === 2) { const [replacementKey, type] = compositeKeyParts; if (type === 'date') { const options = definition[replacementKey] as DateFormatOptions; const date = replacements[replacementKey] as Date; if (date !== undefined && options !== undefined) { const format = new Intl.DateTimeFormat(locale, options) .format(date); interpolated = interpolated.replace(placeholder, format); } } else if (type === 'currency') { const currency = definition[replacementKey] as string; const amount = replacements[replacementKey] as number; const options = { style: 'currency', currency }; const format = new Intl.NumberFormat(locale, options) .format(amount); if (amount !== undefined && currency !== undefined) { interpolated = interpolated.replace(placeholder, format); } } } currentMatch = placeholderRegexp.exec(source); } return interpolateSimple(interpolated, replacements); }
We add a check for the "currency"
suffix, or type
, and proceed to interpolate our argument as currency if our check passes. We grab the amount
from the replacements
map as we would any other argument. We then specify the options
we want to pass to Intl.NumberFormat
, ensuring that the formatting style is "currency"
and that we pass the currency code along to the constructor. We then simply invoke the format
method and replace its returned value for our {placeholder}
.
And that about does it. We could go further, of course, handling general number formatting and interpolation nesting among other things. However, we just wanted to cover the basics in this series, to get an idea of what happens underneath the hood of i18n libraries.
Note: If you’ve been wondering where our React app testbed from part 1 is, we’ve omitted it in part 2 since we have a bit of an issue rendering JSX code snippets on our blog at time of writing. The issue is being worked on and should be resolved in future articles. If you want to see the React app test / demo code, check out part 2’s GitHub repo.
Rolling Out
It’s certainly no trivial task rolling your own JavaScript i18n library. In fact, there are tricky parts to i18n in general. One tool that can help with you with your i18n development is Phrase. Built for software developers, Phrase will update locale files when they change in your GitHub repo. Phrase also provides an easy way to search for your translation strings, access missing translations, and proofread your translations. You can even leverage collaboration tools so that you can save time as you work with your translators in Phrase. Check out Phrase’s full product set, and try it for free for 14 days. You can sign up for a full subscription or cancel at any time.
Along with the download and parsing time saved, making our apps leaner, rolling our own i18n JavaScript library can really help us understand what makes third-party i18n libraries tick. This can only benefit us as web developers working in i18n. I hope you’ve enjoyed this deeper dive into browser i18n, and that you’ve learned as much as I have from “rolling your own”. Happy coding! 🙂