Roll Your Own JavaScript i18n Library with TypeScript – Part 2

Roll Your Own JavaScript i18n Library with TypeScript

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.

/**
 * 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 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

// ...

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 formatted 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 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! 🙂

5 (100%) 3 votes
Comments