DEV Community

loading...
Cover image for i18n - my journey to a simple, powerful and type-safe solution

i18n - my journey to a simple, powerful and type-safe solution

ivanhofer profile image Hofer Ivan Updated on ・10 min read

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

'Hi {0}!' // => outputs to e.g. 'Hi John!'
'Hi {name}!' // or with keyed syntax
Enter fullscreen mode Exit fullscreen mode

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:

'{0} {{apple|apples}}' // => e.g. '1 apple' 
// or the short-syntax:
'{0} apple{{s}}' // e.g. '7 apples'
Enter fullscreen mode Exit fullscreen mode

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

// 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 €'
Enter fullscreen mode Exit fullscreen mode

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:

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)
}
Enter fullscreen mode Exit fullscreen mode

This example uses 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:

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

const LLL = i18nString(locale, formatters)

LLL('Hello {name|uppercase}!', { name: 'world' }) // => 'Hello WORLD!'
Enter fullscreen mode Exit fullscreen mode

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:

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'
Enter fullscreen mode Exit fullscreen mode

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:

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ì'
Enter fullscreen mode Exit fullscreen mode

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 and support you with some basic type checking. You already can:

  • see what locales you can access typesafe locales completion
  • see what keys you can access to call the translation function typesafe translation key completion

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

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

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

const LL = i18nObject<Locales, Translation, TranslationFunctions, Formatters>('en', translations, formatters)
Enter fullscreen mode Exit fullscreen mode

with following types:

type Locales = 'en' | 'de' | 'it'

type Translation = {
   'HI': string
}

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

type Formatters = {
   uppercase: (value: string) => string
}
Enter fullscreen mode Exit fullscreen mode

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 and now we can:

  • see all available locales typesafe locales completion
  • see all available keys to call an translation typesafe translation key completion
  • see if we have missed to add a translation to one of our locales typesafe keys in translations

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:

'Hello {name:string}'
Enter fullscreen mode Exit fullscreen mode

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:

'Total: {0:Cart|calculateSum}'
Enter fullscreen mode Exit fullscreen mode

where the type 'Cart' must be defined in custom-types.ts e.g. as following:

export type Cart = {
   name: string
   price: number
}[]
Enter fullscreen mode Exit fullscreen mode

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:

const formatters = {
   calculateSum: (value: Cart) => // ...
}
Enter fullscreen mode Exit fullscreen mode

With the help of the generator we can also cover the last three problems and we can:

  • see that you need to pass arguments typesafe number of arguments
  • see what type of arguments you need to pass typesafe arguments 1 typesafe arguments 2

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

With this feature we now can also type strings. So when we have the base translation

'Hi {name:string}!'
Enter fullscreen mode Exit fullscreen mode

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:

type ArgName = `${string}{name}${string}`
Enter fullscreen mode Exit fullscreen mode

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

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 - an opinionated, fully type-safe, lightweight localization library for TypeScript projects with no external dependencies

typesafe-i18n in action

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

Discussion (0)

Forem Open with the Forem app