i18n - my journey to a simple, powerful and type-safe solution
Tags: typescript, i18n, svelte, node
Published: 2021-03-16 16:37:37

Two years ago I was looking for an easy solution to localize a TypeScript application that I wrote. The app was written in svelte and I wanted to continue the svelte-way: **easy to use** and **easy on bandwidth**. I tried a lot of i18n packages but could not find any solution that fits my needs.

So, like every software-engineer would do, I hacked together my own solution.

## Table of contents
 - [the problem](#the-problem-i-want-to-solve)
 - [the journey](#the-journey)
 - [the solution](#the-solution)

## The problem I want to solve

I was happy with my solution. It works well, was simple, supported basic plural rules and was only a few kilobytes small. But yet, I came across a few things, that made me always wonder about all key-value based i18n solutions:
 1. what if I have a typo in my translation key?
 2. what if I accidential access a locale, I do not support?
 3. what if I forget to add a translation to one of my locale files?
 4. what if I forget to pass arguments to the translation function?
 5. what if I pass the wrong order of arguments?
 6. what if I pass the wrong type of an argument?

All these questions where not only of theoretical nature, as I encountered them in our project. Most of the times we catched the errors through our code-review process, but still a few bugs passed all the way to the production environment.
Not because it was an self-build i18n solution. No! Because there are some general issues with key-value based i18n solutions: **they don't support static type checking**

Fast forward to a few weeks ago: I had some free time and wanted to learn something new about TypeScript. The first thing tht came to my mind: can there be a typesafe solution to the i18n problem I encountered?

Well, I would not have written this article if the answer wasn't: **YES!**

TypeScript today is very powerful. I recently came across the repository [type-challenges](https://github.com/type-challenges/type-challenges) where a lot of smart people do some crazy magic without code - **only** types.

But can it be so powerful to fulfill my needs? The answer is yes and no at the same time. The type-system is powerful enough, but who should write all these types? But lets begin with the basics:

## The journey

Every i18n solution needs a system to get to your desired output. So lets start with the translation function:

### parsing strings

I wanted a solution where I only need paste a string from a translator into my codebase and maybe only modify some dynamic parts. So I wrote my own little string-parser. The syntax looks like this:
```typescript
'Hi {0}!' // => outputs to e.g. 'Hi John!'
'Hi {name}!' // or with keyed syntax
```
where `{0}` and `{name}` are the dynamic parts, you would need to pass to the translation function.

When calling the translation function the first time, the string is parsed to an optimized object representation. The result is kept in memory and when calling the translation function the second time, no parsing is needed anymore. Only the dynamic parts need to be replaced by the arguments you pass to the function. This can be done fast by browsers, so in a few milliseconds you could easily replace the whole content on-the-fly with a new locale.

### adding some more features

Sometimes you need a little bit more than just passing arguments to be able to translate your application.

#### plural rules

In some parts of your application, you might need your string to adapt depending on a number you pass in as an argument. To rescue, here comes the plural-syntax:
```typescript
'{0} {{apple|apples}}' // => e.g. '1 apple' 
// or the short-syntax:
'{0} apple{{s}}' // e.g. '7 apples'
```
where the first part `'apple'` is the singular version and the second `'apples'` is the plural version. The parts are split by the pipe-character (`|`). Under the hood, the browser's built-in [Intl.PluralRules](https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) is used. It is supported in all modern browsers and can handle a variety of locales.

#### formatting values

Especially when it comes to date and numbers, most locales have their own way to display values. The syntax for formatting values is:
```typescript
// for locale 'en'
'The car costs {0|euro}' // => 'The car costs €19,999.00'

// for locale 'de'
'Das Auto kostet {0|euro}' // => 'Das Auto kostet 19.999,00 €'
```
where `euro` is the name of the formatter it should call.
All formatters are passed when initializing the translation function. In this example we would pass following object to get the locale-dependent currency format:
```typescript
const options = { style: 'currency', currency: 'EUR' }

// for locale 'en'
const euroFormatterEN = Intl.NumberFormat('en', options)

const formattersEN = {
   'currency': (value) => euroFormatterEN.format(value)
}

// for locale 'de'
const euroFormatterDE = Intl.NumberFormat('de', options)

const formattersDE = {
   'currency': (value) => euroFormatterDE.format(value)
}
```
This example uses [Intl.NumberFormat](https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) all modern browsers support. Of course you can write your own solution or use another library to format values.

### translation-functions

Here is a complete example how a setup to translate strings would look like:

```typescript
const locale = 'en'
const formatters = {
   uppercase: (value) => value.toUpperCase()
}

const LLL = i18nString(locale, formatters)

LLL('Hello {name|uppercase}!', { name: 'world' }) // => 'Hello WORLD!'
```
where `i18nString` is function to initialize the translation function.

Of course you don't want to pass in the strings by yourself. You want to have a collection of all your translations in one place. So you could use:

```typescript
const locale = 'en'
const translations = {
   HI: "Hello {name}!",
   RESET_PASSWORD: "reset password"
   /* ... */
}
const formatters = { /* ... */ }

const LL = i18nObject(locale, translations, formatters)

LL.HI({ name: 'world' }) // => 'Hello world!'
LL.RESET_PASSWORD() // => 'reset password'
```
where `i18nObject` is a wrapper around the `i18nString` function.

It could be that you need to call a translation for different locales in the same function e.g. in a server environment where the locale comes from an users session. This can also be done:

```typescript
const localeTranslations = {
   en: { TODAY: "Today is {date|weekday}" },
   de: { TODAY: "Heute ist {date|weekday}" },
   it: { TODAY: "Oggi è {date|weekday}" },
}

const loadLocale = (locale) => localeTranslations[locale]

const initFormatters = (locale) => {
   const dateFormatter =
      new Intl.DateTimeFormat(locale, { weekday: 'long' })

   return {
      date: (value) => dateFormatter.format(value)
   }
}

const L = i18n(loadLocale, initFormatters)

const now = new Date()

L.en.TODAY({ date: now }) // => 'Today is friday'
L.de.TODAY({ date: now }) // => 'Heute ist Freitag'
L.it.TODAY({ date: now }) // => 'Oggi è venerdì'
```
where `i18n` is a wrapper around the `i18nObject` function.

With these three function a variety of use-cases are covered. Next comes the best part:

### type-safety

The `i18nObject` and `i18n` mark the base. These functions are typed using [generics](https://www.typescriptlang.org/docs/handbook/generics.html) and support you with some basic type checking. You already can:

 - see what locales you can access
![typesafe locales completion](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0rv5s0f3csld2wzpqgqm.png)
 - see what keys you can access to call the translation function
![typesafe translation key completion](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1ywvjtgfx95dftu60i87.png)

This support for type-checking is more than most of existing i18n solutions can provide. So I am done, right?

Not quiet yet. We have only covered point 1 and 2 of the [problems](#the-problem-i-want-to-solve) we want to solve.

Here the more complex part begins...

The generic types of the translation-object can help us with our problems. Until here we haven't passed any generic types. The functions infer the types from the objects we pass to the initialize function and use some fallback types to cover the basics.

But someone also has to provide the correct types, so the functions can unfold their full potential. You could write the types yourself and pass them when initializing like in this example:

```typescript
const translations = {
   HI: "Hello {name|uppercase}"
}

const formatters = {
   uppercase: (value: string) => value.toUpperCase()
}

const LL = i18nObject<Locales, Translation, TranslationFunctions, Formatters>('en', translations, formatters)
```
with following types:
```typescript
type Locales = 'en' | 'de' | 'it'

type Translation = {
   'HI': string
}

type TranslationFunctions = {
   'HI': (arg: { name: string }) => string
}

type Formatters = {
   uppercase: (value: string) => string
}
```
When you now try to access the translation by calling `LL.HI()` TypeScript will complain, because you missed to pass an argument. So lets add an argument and call `LL.HI('John')`.

Still an error...

Oh yeah right, we need to pass an Object with a `name` attribute:
`LL.HI({ name: 'John' })`.

Now TypeScript is happy and we can compile our application.

I hope you see the benefit from the additional types. But writing these types is a repetitive task no-one is willing to do. Sounds like a task a computer could and should solve for you. Here the **generator** comes in play:

### The generator

This little helper, assists you by analyzing your base locale file and provides you with the types you need to get a nice i18n experience.

The generator looks for changes in your base-locale file. When a change is detected, it will generate corresponding types for you. You then can use these types to get fully-typed i18n functions. Some wrappers around the base translation-functions are also generated, so you don't have to pass the types by yourself.

The generator needs an opinionated folder structure to do its work.
Your locales will need to be located in the same root-folder. Each locale has its own folder with a `default export` in the `index.ts` file. You will only have one base-locale file, all other locales should have the generated type of `Translation`. Why? Because then you can see if one of your locales is missing a translation.

We now have successfully covered point 3 of our [problems](#the-problem-i-want-to-solve) and now we can:

 - see all available locales
![typesafe locales completion](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d6dsjh09d97igyn6da88.png)
 - see all available keys to call an translation
![typesafe translation key completion](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/je76v0j5jct9fn1gjpl9.png)
 - see if we have missed to add a translation to one of our locales
![typesafe keys in translations](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5w6ii3seyqj9wmcsqbad.png)

all without you needing to write or to pass any types or objects. This is all done automatically for you.

#### formatters

But what happened to the formatters? Well.. the generator can handle this also for you - kind of. It will detect all the formatters you are using in your translation function, and (yes, you guessed it) generate types for your formatter functions. It generates a wrapper object in the `formatters.ts` file where you only need to define your formatting functions. If you forget to define a function, TypeScript will complain and you can't start your application.

There are still some problems left to solve...

#### typed arguments

Because we are parsing your base-translation, we can also define some types in there. The syntax is:
```typescript
'Hello {name:string}'
```
In this example the argument `name` is marked as a `string`. So when you try to call the translation with a wrong type e.g. a number, TypeScript will make you aware of it.

Built-in JavaScript types are supported. If you want to pass your own types or union-types, you need to define them as an export in the `custom-types.ts` file. So if you have the translation:
```typescript
'Total: {0:Cart|calculateSum}'
```
where the type 'Cart' must be defined in `custom-types.ts` e.g. as following:
```typescript
export type Cart = {
   name: string
   price: number
}[]
```
The generator will detect that you want to pass an argument of type `Cart` to your `calculateSum` formatting function and will generate the corresponding type for you. The formatters then must look something like:
```typescript
const formatters = {
   calculateSum: (value: Cart) => // ...
}
```

With the help of the generator we can also cover the last three [problems](#the-problem-i-want-to-solve) and we can:
 - see that you need to pass arguments
![typesafe number of arguments](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7k6dfwauthfchlyd0pni.png)
 - see what type of arguments you need to pass
![typesafe arguments 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ypl3ormaig7smhcu5joh.png)
![typesafe arguments 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sjde9pbuthqa4sqrye0x.png)

I am really happy with my solution. We now can be confident that we call all the translation functions correctly.

But then I encountered another problem:
 - what if in a translation we forget to add an argument the base-translation has?

### bonus round

During my research I stumbled across a new TypeScript feature introduced with version 4.1: [Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)

With this feature we now can also type strings. So when we have the base translation
```typescript
'Hi {name:string}!'
```
we can say that we always expect a translation for that string to contain at least the argument-part `{name}` in it.
This can be typed as following:
```typescript
type ArgName = `${string}{name}${string}`
```
You will notice, that we have omitted the type `string` in the translations. We only need types for our base translation. 

We now also can:
 - see if we forgot to include a parameter in a translation
![typesafe arguments in translation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v7vttkbzif4onmgs72za.png)

But what if someone has not upgraded to the latest TypeScript version yet? Well, the generator only outputs types, your current TypeScript version supports. If you later upgrade and run the generator again, better types will be generated for you ;)

Congratulations, you have reached the end of my story and learned the basics on how a typesafe i18n experience can be accomplished. I am happy to share the outcome of my work with you:

## The solution

**[`typesafe-i18n`](https://github.com/ivanhofer/typesafe-i18n) - an opinionated, fully type-safe, lightweight localization library for TypeScript projects with no external dependencies**

![typesafe-i18n in action](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0qa3fs1fa5v4mynwh1p0.gif)

Advantages of my library are:
 - it is **lightweight** (the base translation function is only 765 bytes gzipped)
 - is **full type-safe** and prevents you from making mistakes
 - it uses an **easy to use syntax** (at least to me :P)
 - has **fast and efficient** type-generation and code execution
 - **supports plural rules**
 - allows **formatting of values** e.g. locale-dependent date or number formats
 - can be used in **any kind of TypeScript applications** (JavaScript is also supported)
 - uses **no external dependencies**

I created some (basic) [examples](https://github.com/ivanhofer/typesafe-i18n/tree/master/examples) so you can see how this package can be used in a variety of projects.

Initially I needed a solution for my svelte-application. So I also created a small wrapper around the i18n-functions. The generator can also export a full-typed svelte-store by setting the `adapter`-[option](https://github.com/ivanhofer/typesafe-i18n#options) to `'svelte'`. Other frameworks can also be added by a few lines of code.

I learned a lot during my journey. I hope you enjoyed my story. Let me know in the comments what you think :)