Roll Your Own JavaScript i18n Library with TypeScript – Part 1

We can often cut down on load and parsing time when we rely less on third-party libraries and roll our own – and this is no different for i18n. In this two-part series, we explore what it would be like to build a JavaScript i18n library from scratch. Part 1 will have us looking at locale detection, defining supported locales for our app, loading translation files, and displaying translations in the UI.

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 cover 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.

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

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

 

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

 

Updated 21/10/2019

 

); } renderFooter() { return ( <p className=”App__Footer”> This is a demo to test the Gaia i18n library </p> ); } render() { return (

{this.renderHeader()} {this.renderLead()} {this.renderFooter()}

); } } export default App;

 

src/components/SelectLanguage.tsx

src/components/Cart.tsx

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.

Let’s implement this API.

src/gaia/gaia.ts

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

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:ß

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

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

src/components/App.ts

Let’s update our init method to handle this.

src/gaia/gaia.ts

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

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

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

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.

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

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

src/gaia/lib/load.ts

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

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.

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

/src/gaia/gaia.ts

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:

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

 

{t(‘lead’)}

 

{t(‘updated’)}

 

); } renderFooter() { return (

{t(‘footer’)}

); } renderContent() { return ( <> {this.renderHeader()} {this.renderLead()} {this.renderFooter()} </> ); } renderLoader() { return <p>Loading…</p>; } render() { return (

{this.state.isLocaleDetermined ? this.renderContent() : this.renderLoader() }

); } } 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

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

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

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, PhraseApp can make your life as a developer easier! Feel free to learn more about the innovative translation management system in our Getting Started Guide.

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. I hope you’ve enjoyed this little journey so far. Stay tuned for the next one :).

Note: You can access all of the above app’s code on Github.

Roll Your Own JavaScript i18n Library with TypeScript – Part 1
Rate this post
Comments