In web applications as I know them, translations are mostly stored in JSONish format and accessed at runtime. Usually, the setup is more complex since the applications needs to support multiple translations. But for now let’s keep it as simple as possible.

const translation = {
  helloWorld: 'Hallo Welt!',
  dialog: {
    title: 'Bestätigung',
    description: 'Möchten Sie fortfahren?'
  },
  actions: {
    buttons: {
      submit: 'Bestätigen',
      cancel: 'Abbrechen'
    }
  }
}

To retrieve and display the translation at runtime there is a method called translate which returns the corresponding translation for a key from the translation object:

type StringOnlyJson = string | { [property: string]: StringOnlyJson };

const translate = (translationKey: string): string => {
  const result = translationKey
    .split('.')
    .reduce(
      (json, propertyName) => typeof json === 'object' ? json[propertyName] : undefined,
      translation as StringOnlyJson | undefined
    );
  return typeof result === 'string' ? result : translationKey;
};

If the method gets called with dialog.title, it will return Bestätigung:

translate('dialog.title'); // returns 'Bestätigung'

But what happens if an invalid or incorrect translation key is passed to the method? The method cannot find the corresponding translation and returns the passed key as a fallback. In the following example dialog.header is passed instead of dialog.title.

translate('dialog.header'); // returns 'dialog.header'

In my experience this error pattern occurs quite often. A developer simply makes a typo in the translations or changes the naming of a translation key without adjusting them at each source code location. This results in the user seeing only the technical key instead of the expected translation. Such a fallback is helpful because the user can likely continue working with the application instead of getting a blank label or even worse an error message.

Dialog with missing translation

As mentioned at the beginning, translations are usually loaded at runtime. Therefore, such errors do not occur earlier than at runtime. To detect and avoid such defects, an extensive test suite or a high manual testing effort is required. In worst case, such defects occur in production and are displayed to the end user.

For this reason, errors should be found as early as possible and that is usually at compile time.

Wouldn’t it be great if the translation keys could be checked automatically? Let’s jump into the power of TypeScript.

In TypeScript there are these String Literal Types. Isn’t it possible to check the translation keys at compile time and inform the developer about his mistake? It would only need a list of all possible translation keys. That is worth a try:

type TranslationKey =
  | 'helloWorld'
  | 'dialog.title'
  | 'dialog.description'
  | 'actions.buttons.submit'
  | 'actions.buttons.cancel';

Afterwards the signature of the translate method can be changed to use the type TranslationKey for the translationKey parameter instead of just string:

const translate = (translationKey: TranslationKey): string => {
  // same code as above
};

This change leads to the fact that the method translate must only be called with one of the previously defined values. All other strings are treated as errors by the compiler.

Back to the example from above. What happens if the translate method gets called with the correct dialog.title and the incorrect dialog.header?

translate('dialog.title'); // compiles fine
translate('dialog.header'); // compiler error: Argument of type '"dialog.header"' is not assignable to parameter of type 'TranslationKey'.

The compiler gives an error message on the second call. The program does not compile and the developer is forced to correct the mistake.

This solution works very well and is easy to implement. Problem solved. :-)

Well…

This solution requires the developer to maintain all translation keys twice: once in the actual translation and a second time in the definition of the TranslationKey type. These two definitions must always be kept in sync to avoid the above mentioned errors of missing translations. This process is tedious, error-prone and in the end does not lead to any improvement.

Is there no way to create the TranslationKey type automatically? The TypeScript compiler would only have to extract the translation keys from the JSON object and concatenate them with a dot.

Indeed, TypeScript can derive the TranslationKey!

type DeepKeysOf<T, Key extends keyof T = keyof T> = Key extends string
  ? T[Key] extends string ? Key : `${Key}.${DeepKeysOf<T[Key]>}`
  : never;

type TranslationKey = DeepKeysOf<typeof translation>;

The type TranslationKey is identical to the manual definition from above. The behavior of the compiler is as well identical, a call to the translate method with an incorrect translation key will raise an error.

But what exactly does the DeepKeysOf type do? Let’s look at the crucial part first:

T[Key] extends string ? Key : `${Key}.${DeepKeysOf<T[Key]>}`

T is the type definition of the translation object, Key is a property of the translation object, T[Key] is therefore the value of this property. T[Key] can be either a string, like 'Hello World!' or another object, like the value of dialog. In the first case, the execution can be stopped and the result is simply helloWorld. In the second case a recursion is called, which adds . to the Key and again uses the type definition DeepKeysOf for the object of T[Key]. With the help of this recursion it is possible to use arbitrarily deeply nested translation objects.

A practical example for DeepKeysOf<typeof translation>: T is the entire translation object, Key is a property of this, i.e. helloWorld, dialog or action. T[Key] is the value of this property, for the Key helloWorld it is 'Hello World!' , for dialog it is the object { title: 'Bestätigung', description: 'Möchten Sie fortfahren?' }. Thus, if Key is helloWorld then the expression T[Key] extends string holds true and thus the result of the expression will be helloWorld. On the other hand, if Key is dialog, then T[Key] is an object, the expression holds false, and the result is a concatenation of dialog. (including the dot) with the result of DeepKeysOf<T['dialog']>.

However, it still has to be clarified how to iterate through the different properties within an object. For this purpose keyof and a type alias named Key is used: Key extends keyof T = keyof T. keyof T is an alias for all properties of the object T and allows in that way an iteration through all properties. Key then contains the current property selected by the iteration through keyof T. The actual iteration is performed by TypeScript itself.

As a last point there is the wrapper Key extends string ? ... : never around the actual expression (abbreviated by …). In the translation object Key is always a string, so this expression is actually not relevant. But TypeScript does not actually know this, because this has not been defined. But for the later concatenation with . TypeScript expects a string (or several other types). By the way, the else branch with the result never is not called with the translation object. But if the object would contain some keys which are not string (i.e. number, or similar), then using never the corresponding invalid branches in the input object would be ignored.

This entire implementation is also available for simple follow up on the TypeScript Playground.

In the end, this solution is very powerful, thanks to TypeScript’s extensive type system. Translations are no longer as error-prone as I know from my past. Overall, a single type definition increases the quality of the software and this quality can also be checked automatically at compile-time.

Comments are welcome on Twitter or LinkedIn.