Type Check Your Polyglot Dictionary with TypeScript

I recently started using Airbnb’s Polyglot package to provide some basic I18n for my project. I only needed it to manage translations and do string interpolation for pluralization. I soon found (after a few misspelled translation keys), that I needed to type check my translations for it.

Polyglot takes a phrases object that looks like this:


const phrases = {
  'en-US': {
    page1: { // nesting is vital to keep names short
      helloWorld: 'Hello World'
    }
  },
  'es-MX': {
    page1: {
      helloWorld: 'Hola Mundo'
    }
  }
};

If you give Polyglot a string path to a translation (i.e. ‘page1.helloWorld’), it will return the translated value for that key in the phrases object it was instantiated with.
In the browser, you can get the translation for a given locale like so:


const locale = i18n.LanguageCode;
const polyglot = new Polyglot({phrases: phrases[locale]});

const hello = polyglot.t('page1.helloWorld');
console.log(hello); // Hola Mundo

It’s pretty quick to get out of the gate. However, as you add text to your app, these phrases objects will get bigger and more difficult to maintain. And if you’re quickly building out a UI with lots of text, you need to be able to easily add text without having to fire up your app in all the supported locales to verify that it works for every permutation of UI state and locale. So let’s type check Polyglot’s t function!

First off, we’re not actually going to create a type for t, per se. We’ll start with an example of desired behavior and work from there:


// we want this typo to get caught by TypeScript's compiler
const hello = polyglot.t('page1.heloWorld'); 

A naive way to start is to create some string literals, and create a wrapper for t that will type check against a union of those string literals, i.e.:


type Phrases = 'page1.helloWorld' | 'page1.foo';

type TypedPolyglot = (key: Phrases) => string;
const t: TypedPolyglot = (key: Phrases): string => polyglot.t('page1.helloWorld');
const hello = t('page1.heloWorld'); // caught!

The trouble is that page1.helloWorld is not a particularly helpful string constant because 1) it is not coupled to the actual phrases object that you gave to Polyglot at the beginning, so programmer error will cause run-time errors instead of compile-time errors, and 2) the whole point of nesting the phrases object was to namespace translations to make keys shorter and more descriptive, and having to reference the whole path defeats the purpose.

So, let’s couple it to the actual phrases object.


type PhrasesDictionary = {
  [locale: Locale]: Phrases
};
type Phrases = {
  page1: Page1
};
type Page1 = {
  helloWorld: string
};
const spanish: Phrases = {
  page1: {
    helloWorld: 'Hola Mundo'
  }
}
const english: Phrases = {
  page1: {
    helloWorld: 'Hello World',
  }
}
const dictionary: PhrasesDictionary = {['en-US']: english, ['es-MX']: spanish}
const polyglot = Polyglot({phrases: dictionary['es-MX']});

Now we have types coupled to the actual phrases object, but at the cost of a singular type we can use to type check our wrapper for t. No matter. Let’s use typed functions to curry our way to compilation error bliss.


// keep this with your translation definitions
export function keyCreator<T>(key: keyof Phrases, subKey: keyof T) {
  return `${key}.${subKey}`;
}

Then, in your template, wrap your polyglot instance in an anonymous function that takes the relevant subkey, and feed it a keyCreator function curried with the top-level key. The keyCreator generic type removes another typo opportunity.


const t = (subKey: keyof Page1) => polyglot.t(keyCreator<Page1>('page1', subKey));
t('helloWorld') // no error
t('heloWorld') // error!

Lovely! This code won’t compile because a key has been typo’d. The downside here is that wrapping and typing your polyglot instance in the template is a little clunky, but since it has to wrap a polyglot instance, you’re left with little choice, except maybe creating a translator-creator function which would take the top-level key and polyglot instance and return a typed wrapper, like so:


export function translatorCreator<T>(key: keyof Phrases, p: Polyglot): (subkey: keyof T) => string {
  return (subkey: keyof T) => p.t(keyCreator<T>(key, subkey))
}

And then use it in a template:


const t = translatorCreator<Page1>('page1', polyglot);
t('helloWorld'); // no error
t('heloWorld'); // error!

Whew! That’s a pretty nice and neat solution that provides type checking for your Polyglot phrases object. You can play around with this code in this TypeScript Playground

If you’ve found your own path to the promised land of compilation errors for translation keys, leave a comment!