Roll Your Own JavaScript i18n Library with TypeScript – Part 2

We can often reduce load and parsing time when we rely less on third-party libraries and establish our own. This goes also for i18n. In this two-piece series, we explore the possibility of building our own JavaScript i18n library from scratch. Part 2 will have us diving into interpolation: dynamic variables in translated strings; plurals, dates, and currency.

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:

  1. lean code that gives us exactly what our app needs, saving kilobytes downloaded and code parsing time in the browser;
  2. 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.

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

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:

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.

In our translation files, we can specify a placeholder for this argument.

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

src/gaia/lib/types.ts

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

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?

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

public/lang/ar-eg.json

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

src/gaia/lib/types.ts

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

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.

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

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

/src/gaia/lib/types.ts

Note: If you’re following along with the types and are wondering why the DateFormatOptions interface defines a boolean type; Intl.DateFormat formatting options have an hour12 property which can be true or false. 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

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

The cool thing is we don’t have to specify anything extra for our other locales. Our French file can look like this:

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.

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 formatted interpolation. We just need to handle currencies in our interpolateFormatted function.

src/gaia/lib/interpolate.ts

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 PhraseApp. Built for software developers, PhraseApp will update locale files when they change in your Github repo. PhraseApp 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 PhraseApp. Check out PhraseApp’s full feature 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! 🙂

Roll Your Own JavaScript i18n Library with TypeScript – Part 2
5 (100%) 3 votes
Author
Mohammad @Mohammad Ashour
Comments