Software localization

Roll Your Own JavaScript i18n Library with TypeScript – Part 1

What would it be like to build a JavaScript i18n library from scratch? And on your own? In part 1 of our series, we'll set up the basic i18n functionality.
Software localization blog category featured image | Phrase

This is part 1 of a two-part series. If you're confident about building the basic functionality of an i18n library, you can jump ahead to part 2 where we cover interpolation.

Sometimes, when working on a custom JavaScript app, we can get a bloat of third-party libraries. While this can initially help us roll out features faster, the kilobytes of these libraries can add up, weighing down our app. Our users have to download these extra kilobytes – sometimes on slow connections. And the browser JavaScript engine often has to parse large parts of these libraries even when we use small subsets of them. Modern JavaScript bundlers like Webpack can eliminate some of these problems via pruning out unused code. However, the way some third-libraries are built means that they have a lot of internal dependencies, which reduces the amount of code we can shake off. When it comes to being as lean as possible, nothing beats rolling our own solution that is tailor-made for our app.

Rolling our own browser i18n library will give us these size and performance benefits. It will also shed some light on the internals of popular third-party JavaScript i18n libraries, giving us insight on how they might work. This deeper knowledge will make us better i18n developers. I certainly learned some new things building the library I'll showcase in this article, and I hope you can learn a couple of things from this process yourself. And rolling our own i18n library can also be a lot of fun, so let's get cooking.

We know that any i18n library worth its salt has to give us the following...

Basic Functionality (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 (Part 2)

  • Handling dynamic arguments in our translation strings
  • Handling singular/plural forms
  • Formatting dates
  • Formatting currency

This feature set is a good start for most apps. In this article (part 1), we'll walk you through basic i18n functionality. We'll handle interpolation in part 2.

We'll create a test bed that we can use to try out our library's features as we build them. I'll use a React app to test here, but you can use Angular, Vue, or any other view library (or no library at all) if you want. Our i18n library will be designed to work independently of any other library or framework.

🔗 Resource » Check out our Ultimate Guide to JavaScript Localization for a deep dive into all the steps you need to make your JS apps accessible to international users.

TypeScript

We'll use TypeScript to build out our library. You've probably heard about Microsoft's TypeScript by now. If you haven't, just know that it's a strongly-typed superset of JavaScript. It actually compiles down to JavaScript and is a way to help us better organize and document our programs. Code written in TypeScript (or having TypeScript type definitions) gives us helpful information like function signatures and object shapes/interfaces. The language also helps us detect type errors at compile time, so we save some time because we catch these errors before we run our code. You'll see TypeScript in action here, but don't worry if you don't know it. You can mostly ignore the typing in the following code and just treat the code as plain old JavaScript. For most intents and purposes, it is.

Our Test Bed

Test application | Phrase

Our test application will be a simple demo shopping cart. We'll assume that this is an SPA (single page application) that we want to internationalize on the client. To stay focused on browser / JavaScript work, we'll hard-code item data and user information that would normally come from the server. Here's the code for our little test bed.

🗒 Note » You can access all the code for the app built in this article on GitHub.

src/components/App.tsx

import React, { Component } from 'react';

import './App.css';

import Cart from './Cart';

import SelectLanguage from './SelectLanguage';

class App extends Component {

    renderHeader() {

        return (

            <header className="App__Header">

                <h1>Your Cart</h1>

                <SelectLanguage

                    value="en"

                    onChange={() => { }}

                />

            </header>

        );

    }

    renderLead() {

        return (

            <div>

                <p>

                    Evening, Adam. Here's what's currently in your shopping cart.

                </p>

                <p>Updated 21/10/2019</p>

            </div>

        );

    }

    renderFooter() {

        return (

            <p className="App__Footer">

                This is a demo to test the Gaia i18n library

            </p>

        )

    }

    render() {

        return (

            <div>

                {this.renderHeader()}

                {this.renderLead()}

                {this.renderFooter()}

            </div>

        )

    }

}

export default App;

src/components/SelectLanguage.tsx

import React from 'react';

interface I18nConfig {

    supportedLocales: {

        [key: string]: string;

    };

}

const i18nConfig: I18nConfig = {

    supportedLocales: {

        en: 'English',

        ar: 'عربي',

        fr: 'Français',

    },

}

function renderOption(value: string, label: string) {

    return (<option key={value} value={value}>{label}</option>);

}

interface SelectLanuageProps {

    value: string;

    onChange(value: string): void;

}

function SelectLanguage(props: SelectLanuageProps) {

    return (

        <select

            value={props.value}

            onChange={e => props.onChange(e.target.value)}

        >

            {

                Object.keys(i18nConfig.supportedLocales).map((key) => {

                    return renderOption(key, i18nConfig.supportedLocales[key]);

                })

            }

        </select>

    );

}

export default SelectLanguage;

src/components/Cart.tsx

import React, { Component } from 'react';

import './Cart.css';

class Cart extends Component {

    render() {

        return (

            <table className="Cart">

                <thead>

                    <tr>

                        <th>Item</th>

                        <th>Quantity</th>

                        <th>Price</th>

                        <th>Subtotal</th>

                    </tr>

                </thead>

                <tbody>

                    <tr>

                        <td>Under-inflated balloons</td>

                        <td>2</td>

                        <td>$2.99</td>

                        <td>$5.98</td>

                    </tr>

                    <tr>

                        <td>Over-priced smartphone</td>

                        <td>1</td>

                        <td>$2299.99</td>

                        <td>$2299.99</td>

                    </tr>

                    <tr>

                        <td>Diamond-studded selfie stick</td>

                        <td>4</td>

                        <td>$284.99</td>

                        <td>$1139.96</td>

                    </tr>

                </tbody>

            </table>

        );

    }

}

export default Cart;

This is mostly JSX that's defining our markup. We don't have much in the way of i18n here, as our UI strings are hard-coded in English. You may have noticed that our SelectLanguage component isn't doing much either. Let's remedy all this step by-step while we build our library. As the wise would advise, we'll start at the beginning.

Locale Detection

We need a way to initialize our library and have it detect the user's locale from her or his browser settings. Let's call our library Gaia after the Greek Mother Earth goddess. May be our API can work like the following.

gaia.init()

    .then((locale) => {

        // finished loading and detected locale

    });

Let's implement this API.

src/gaia/gaia.ts

import { getUserLocale } from './lib/user-locale';

let _locale: string;

const gaia = {

    init(): Promise<string> {

        return new Promise((resolve, reject) => {

            _locale = getUserLocale();

            resolve(_locale);

        });

    }

};

export default gaia;

We start with a singleton gaia object with an init method.  init resolves the user's locale, stores it privately, and resolves its promise when it detects the locale. The method delegates to an external getUserLocale function, which we'll take a look at next.

🗒 Note » We're using a promise because we'll do some async work a bit later on, so the promise is just a bit of boilerplate for now.

Reading the User's Locale from the Browser

src/gaia/lib/user-locale.ts

declare global {

    interface Window {

        // IE navigator lanuage settings (non-standard)

        userLanguage: string;

        browserLanguage: string;

    }

}

export function getUserLocale(): string {

    return window.navigator.language ||

        window.browserLanguage ||

        window.userLanguage;

}

Our function will get the user's locale as per her or his browser settings, taking care of cross-browser differences behind the scenes. This locale the window object provides will be the first one set in the user's list of browser preferences. Firefox on macOS, for example, provides the following dialog for setting locales:ß

Webpage language settings | Phrase

The first locale in this list is exposed to us by the browser and is dealt with differently in different browsers. The standard way to access the locale is via window.navigator.language. This property is not available in Internet Explorer, however, so if we want to support that browser, we need to check one of two other properties: window.browserLanguage and window.userLanguage. Our function will try the standard property first and fallback on the IE ones if needed.

🗒 Note » To use the non-standard IE properties without TypeScript squawking, we use the declare keyword to notify TypeScript that they are indeed  window properties and they're OK to use.

🗒 Note » At time of writing the standard window.navigator.language will, in desktop Chrome, Android Chrome, and the default Android browser, return the browser's UI language, and not the preference the user set in the browser.

Supported Locales and Fallback Locale

Well, we now have an idea of the user's locale. Our app will likely only support a finite number of locales, however, and our library will need to know about those so that it can check against them and always set a supported locale. We'll probably also want to have the option to explicitly set a fallback locale in case we don't support the user's locale at all.

Let's move our demo app's i18n configuration from the SelectLanguage component to its own file for reuse. We'll add the fallback setting to it as well.

src/config/i18n.ts

interface I18nConfig {

    supportedLocales: {

        [key: string]: string;

    };

    fallbackLocale: string;

}

const i18nConfig: I18nConfig = {

    supportedLocales: {

        en: 'English',

        ar: 'عربي',

        fr: 'Français',

    },

    fallbackLocale: 'en',

};

export default i18nConfig;

We would like to be able to pass these to our i18n library and have them influence its locale resolution.

src/components/App.ts

// ...

import i18nConfig from '../config/i18n';

class App extends Component {

    componentDidMount() {

        const { supportedLocales, fallbackLocale } = i18nConfig;

        gaia

            .init({

                supportedLocales: Object.keys(supportedLocales),

                fallbackLocale

            })

            .then((locale) => {

                console.log({ locale });

            });

    }

// ...

Let's update our init method to handle this.

src/gaia/gaia.ts

import resolveUserLocale from './lib/user-locale';

import { normalize, containsNormalized } from './lib/util';

let _locale: string;

let _supportedLocales: ReadonlyArray<string>;

const gaia = {

    init(options: {

        supportedLocales: string[],

        fallbackLocale?: string,

    }): Promise<string> {

        return new Promise((resolve, reject) => {

            if (!options.supportedLocales ||

                options.supportedLocales.length === 0

            ) {

                return reject(new Error(

                    'No supported locales given. Please provide ' +

                    'supported locales.'

                ));

            }

            _supportedLocales = Object.freeze(

                options.supportedLocales.map(normalize)

            );

            if (options.fallbackLocale &&

                !gaia.isSupported(options.fallbackLocale)

            ) {

                return reject(new Error(

                    `Fallback locale ${options.fallbackLocale} is not in ` +

                    'supported locales given: ' +

                    `[${_supportedLocales.join(', ')}].`

                ));

            }

            _locale = resolveUserLocale(_supportedLocales) ||

                options.fallbackLocale ||

                _supportedLocales[0];

            resolve(_locale);

        });

    },

// ...

We first check that the now required supportedLocales array was provided, letting the user know that we have a problem if it wasn't. We then store the supported locales list privately so that it's cached for later. We normalize the locales before we store them, which just means we convert all locale codes to lowercase and replace underscores with dashes. So "en_US" is normalized to "en-us". This will make our locale comparisons easier later.

If we were given a fallbackLocale, we make sure that it's supported by our app by verifying it against the given supported locales using the public method, gaia.isSupported.

src/gaia/gaia.ts

// ...

const gaia = {

    // ...

    get supportedLocales(): ReadonlyArray<string> {

        return _supportedLocales;

    },

    isSupported(locale: string): boolean {

        return containsNormalized(gaia.supportedLocales, locale);

    },

    // ...

isSupported uses a utility function called containsNormalized to make sure that the given locale is contained within our supported locales array. containsNormalized simply checks the given locale against our supported locales without case or separator sensitivity. So if our _supportedLocales array contains the item "fr-fr", the given locale values  "fr-FR", "fr_FR", and "Fr_fR" would all be considered supported.

When setting our resolved/current locale, we can no longer just get the user's browser locale and call it a day. We have to make sure that this locale is supported by our app, and we have to take fallbacks into account.

src/gaia/gaia.ts

const gaia = {

    init(

     // ...

    ) {

        // ...

            _locale = resolveUserLocale(_supportedLocales) ||

                options.fallbackLocale ||

                _supportedLocales[0];

// ...

We use a new resolveUserLocale function to accomplish both getting the user's browser locale and resolving it against our app's supported locales.

src/gaia/lib/user-locale.ts

import { normalize, languageCode, find } from "./util";

export default function resolveUserLocale(

    supportedLocales: ReadonlyArray<string>,

): string | undefined {

    const userLocale = normalize(getUserLocale());

    const exactMatch = find(

        supportedLocales,

        (supported) => supported === userLocale,

    );

    if (exactMatch) { return exactMatch; }

    const userLanguageCode = languageCode(userLocale);

    const languageCodeMatch = find(

        supportedLocales,

        (supported) => languageCode(supported) === userLanguageCode,

    );

    if (languageCodeMatch) {

        return languageCodeMatch;

    }

}

The resolveUserLocale function uses our previously implemented getUserLocale function to get the user's browser locale. It then checks this locale against the given array of supported locales. If it finds an exact match it resolves to that. Otherwise it sees if the user's locale has the same language as one of the supported locales, and resolves to the first language match if it finds it.

So if our supported locale list is ["en-us", "ar-eg", "fr"] and the user's browser locale is "en-IR", then the resolved locale will be "en-us". Similarly, "ar-SA", "en", and "fr-CA" will match  to their respective supported locales. Of course, if the user's locale is an exact match like "ar-eg", we will resolve to that.

🗒 Note » resolveUserLocale makes use of a custom find  function. This function simply uses Array.prototype.find if it exists and falls back on a for loop check if the native Array.prototype.find does not exist. The fallback is primarily to handle Internet Explorer, which doesn't support the native method. If you want dive deeper into the code, check out the full app's source on GitHub.

🗒 Another note » languageCode, when given "en-us", returns "en".

Loading Translation Files

Now that we have supported locales handled, let's actually get translations working by loading and parsing translation files. Since we're working on the web, we'll use JSON as the format of our translation files. JSON gives us free parsing and key-value mapping.

public/lang/en.json

{

    "title": "Your Cart",

    "lead": "Evening, Adam. Here's what's currently in your shopping cart."

    // ...

}

Let's build our loader and wire it up to Gaia's intializer.

src/gaia/lib/load.ts

import 'whatwg-fetch';

export default function load(locale: string): Promise<Translations> {

    const url = `/lang/${locale}.json`;

    return fetch(url)

        .then((response) => {

            if (!response.ok) {

                throw new Error(

                    `${response.status}: Could not retrieve file at ${url}`

                );

            }

            return response.json();

        });

}

Our load function takes a locale and grabs its translation JSON file using the built-in browser fetch API. We import the whatwg-fetch package because it polyfills fetch for browsers that don't support it. After we grab the file, we simply parse it to JSON so that we can key into it. Alright, let's wire our loader up.

🗒 Note » Wondering what is the Translations TypeScript type we're using? It's simply an interface of a string-to-string map: [key: string]: string.

src/gaia/gaia.ts

import load from './lib/load';

// ...

let _locale: string;

let _translations: Translations;

let _supportedLocales: ReadonlyArray<string>;

const gaia = {

    init(options: {

        supportedLocales: string[],

        fallbackLocale?: string,

    }): Promise<string> {

        return new Promise((resolve, reject) => {

            // ...

            _locale = resolveUserLocale(_supportedLocales) ||

                options.fallbackLocale ||

                _supportedLocales[0];

            return loadAndSet(_locale).then(() => resolve(_locale));

        });

    },

    // ...

};

export default gaia;

// Private

function loadAndSet(locale: string): Promise<void> {

    return new Promise((resolve, reject) => {

        if (!gaia.isSupported(locale)) {

            return reject(new Error(`Locale ${locale} is not in supported ` +

                `locales given: [${_supportedLocales.join(', ')}].`));

        }

        const normalizedLocale = normalize(locale);

        return load(normalizedLocale).then((json) => {

            _locale = normalizedLocale;

            _translations = json;

            return resolve();

        });

    });

}

We now actually load the translation file in our initializer. We use a loadAndSet function to do this, which will allow us to reuse the relevant logic when we implement manual locale setting (we'll get to this in a minute). Because loadAndSet will be used from outside the initializer in the future, it guards against unsupported locales. It then loads the translation file using our load function and stores the file's JSON as the current _translations for later retrieval. The function also stores the given locale in the private _locale variable, which is our library's source of truth for the current locale.

We're so close to actually using our library to show translated content.

Displaying a Translation

We need a way to grab translated strings from the currently loaded translation file and display them in our UI. i18n libraries usually provide this functionality as a function with a short name.

h1Element.innerHTML = gaia.t('user_profile_title');

With our current implementation, the t function should be simple enough to build.

/src/gaia/gaia.ts

// ...

let _translations: Translations;

// ...

const gaia = {

    // ...

    t(key: string): string {

        return _translations[key] || key;

    },

};

export default gaia;

export const t = gaia.t;

// ...

We simply take a key index and attempt to access the key's corresponding value from our loaded _translations. If the key the user is trying to access isn't defined in the current translation file, we default to the key itself to give the user an indication that there's a missing translation. This also allows for the keys to be actual content in a source language, like English:

{

    "Hello my friend: "Bonjour, mon ami"

}

Since gaia.t will be used all over our UI, we create a named export for it, t without a namespace, a convenient shorthand.

OK, let's update our UI to use localized values.

Internationalizing the UI

src/components/App.tsx

import React, { Component } from 'react';

import './App.css';

import Cart from './Cart';

import gaia, { t } from '../gaia/gaia';

import i18nConfig from '../config/i18n';

import SelectLanguage from './SelectLanguage';

class App extends Component {

    state = {

        isLocaleDetermined: false,

    }

    componentDidMount() {

        const { supportedLocales, fallbackLocale } = i18nConfig;

        gaia

            .init({

                supportedLocales: Object.keys(supportedLocales),

                fallbackLocale

            })

            .then((locale) => {

                this.setState({ isLocaleDetermined: true });

            });

    }

    renderHeader() {

        return (

            <header className="App__Header">

                <h1>{t('title')}</h1>

                <SelectLanguage

                    value="en"

                    onChange={() => { }}

                />

            </header>

        );

    }

    renderLead() {

        return (

            <div>

                {t('lead')}

                {t('updated')}

            </div>

        );

    }

    renderFooter() {

        return (

            <div>{t('footer')}</div>

        );

    }

    renderContent() {

        return (

            <>

                {this.renderHeader()}

                {this.renderLead()}

                {this.renderFooter()}

            </>

        )

    }

    renderLoader() { return <p>Loading...</p>; }

    render() {

        return (

            <div>

                {this.state.isLocaleDetermined ?

                    this.renderContent() :

                    this.renderLoader()

                }

            </div>

        );

    }

}

export default App;

We show some hard-coded loading text while our i18n library initializes and loads the current translation file. Once the file is ready, we show our page content, which is now internationalized and will contain translated content via our new t function.

We now have basic i18n/l10n working. Let's expand on this a little bit. Our automatic locale detection won't always be perfect. A user may be on a public computer, for example, or one that he or she doesn't own. In this case, the locale set on the browser may not suit her or him at the time.

Manually Setting the Locale

It's always a good idea to allow for a manual setting of the locale. Our UI has a language selector in place, but it's not doing much right now. Let's get it working. We'll need a way to tell Gaia that we want to set or force a locale, and we'll want to do this at initialization first.

src/gaia/gaia.ts

const gaia = {

    init(options: {

        supportedLocales: string[],

        locale?: string,

        fallbackLocale?: string,

    }): Promise<string> {

        return new Promise((resolve, reject) => {

            // ...

            if (options.locale) {

                _locale = options.locale;

            } else {

                _locale = resolveUserLocale(_supportedLocales) ||

                    options.fallbackLocale ||

                    _supportedLocales[0];

            }

            return loadAndSet(_locale).then(() => resolve(_locale));

        });

    },

// ...

We add an optional locale parameter to our init method. If this parameter is provided, we skip automatic locale resolution and attempt to set it as a forced locale. This allows the developer to specify a locale from the outset, which is handy if the application has stored the current user's locale from a previous visit and wants to reload it, for example.

However, this won't allow our language switcher to work just yet. We also need a way to set a locale after Gaia has initialized. Since we have our locale loading and setting in a reusable function, implementing this logic is trivial.

src/gaia/gaia.ts

// ...

const gaia = {

    // ...

    get locale() { return _locale; },

    setLocale(locale: string): Promise<void> {

        return loadAndSet(locale);

    },

// ...

We expose a public setLocale method that simply wraps our private loader/setter. We also expose the currently set locale as a public property to keep our library's API a bit symmetrical.

We can now implement our language switching functionality.

src/components/App.tsx

// ...

class App extends Component {

    state = {

        locale: 'en',

        isLocaleDetermined: false,

    }

    componentDidMount() {

        const { supportedLocales, fallbackLocale } = i18nConfig;

        gaia

            .init({

                supportedLocales: Object.keys(supportedLocales),

                locale,

                fallbackLocale

            })

            .then((locale) => {

                this.setState({ locale, isLocaleDetermined: true });

            });

    }

    onSelectLocale = (newLocale: string) => {

        this.setState({ isLocaleDetermined: false }, () => {

            gaia.setLocale(newLocale)

                .then(() => this.setState({

                    locale: newLocale,

                    isLocaleDetermined: true

                }));

        });

    }

    renderHeader() {

        return (

            <header className="App__Header">

                <h1>{t('title')}</h1>

                <SelectLanguage

                    value={this.state.locale}

                    onChange={this.onSelectLocale}

                />

            </header>

        );

    }

// ...

We use the new locale parameter to initialize our i18n library with a locale that we choose, and we wire up our language selector, SelectLanguage, so that gaia.setLocale is called when the user selects a new locale. This will cause Gaia to load the language file of the newly selected locale, and our UI will react to this. We now have a working language selector.

Rolling Up

Writing code for the localization of your app is one task; working with translations – something totally different. A large number of translations for multiple languages may overwhelm you and eventually lead to confusion among users. Luckily, Phrase can make your life as a developer easier! Feel free to learn more about the innovative translation management system in our Phrase Localization Suite.

I hope you've enjoyed this little journey so far. :)

We have the basics of i18n well in place, but we have a bit more to do. We need to handle dynamic arguments in our translated strings, plurals, dates, and currency. We'll pause here and resume with these interpolation features in the next part of this series, part 2. Check it out here.

🗒 Note » You can access all of the above app's code on GitHub.