TypeScript : résolution des modules JSON

resolveJsonModule

Cette fonctionnalité de TypeScript disponible depuis la version 2.9 du langage permet d'améliorer la sûreté du typage (type safety) dans quelques cas d'utilisations qui, bien qu'assez spécifiques, peuvent s'avérer critiques pour une application.

L'article n'a pas pour vocation de faire l'inventaire desdits cas mais de présenter le concept avec des exemples pratiques, transposables à tout type d'applications.


Configuration

La résolution des modules JSON est activée par l'option de compilation resolveJsonModule et permet aux développeurs d'écrire du code exploitant la structure (déduite) du JSON importé.

Les exemples ci-après utilisent un fichier tsconfig.json contenant notamment les valeurs suivantes :

"compilerOptions": {
  "module": "commonjs",
  "esModuleInterop": true,
  "resolveJsonModule": true, // 💟
  "strict": true",
  // ...
}

 

Cas pratiques

Fichier de configuration

Il est commun d'utiliser un fichier de configuration dans les scripts et applications JS/TS. Dans l'exemple suivant (totalement fictif et scabreux, mais tout aussi démonstratif), un script est responsable de vérifier le bon fonctionnement (health check) de services d'envoi de SMS.

sms-settings.json

{
  "providers": {
    "twilio": {
      "apiKey": "1b3b5543-e4c5",
      "expectedMessage": "Hello from Twilio",
      "expectedStatus": 200
    },
    "messagebird": {
      "apiKey": "b78ab91a-4fe5",
      "expectedMessage": "Hi from Messagebird",
      "expectedStatus": "200"
    }
  }
}

Notez la valeur "200" typée comme une chaîne de caractères dans messagebird.expectedStatus (⚠ Spoiler alert : on veut provoquer une erreur de compilation 😁).

Ces configurations sont importées et utilisées par le script suivant :

/* script.ts */
import settings from './sms-settings.json';  
// [...]

const providers = settings.providers;

for (const provider of Object.entries(providers)) {  
  checkSmsProviderStatus(provider[0], provider[1]);
}

/* sms-check.ts */
interface ISmsProviderConfiguration {  
  apiKey: string;
  expectedMessage: string;
  expectedStatus: number;
}

function checkSmsProviderStatus(  
  providerName: string, 
  config: ISmsProviderConfiguration
) {
  // [...]
}

Le type de providers (Ligne 5) est ainsi déduit et conforme au contenu de sms-settings.json 😍 :

resolveJsonModule-typeof-providers

Le type de provider[1] (Ligne 8) peut être rapproché à :

{  
  apiKey: string;
  expectedMessage: string;
  // 200 et "200" entraînent une déduction de : "string" ou "number"
  expectedStatus: string | number;
}  

et n'est donc pas compatible avec le type ISmsProviderConfiguration spécifié par checkSmsProviderStatus (à cause du type de expectedStatus) :

$ tsc

script.ts:8:39 - error TS2345: Argument of type [...] is not assignable  
to parameter of type 'ISmsProviderConfiguration'.  
[...]
    Types of property 'expectedStatus' are incompatible.
      Type 'string' is not assignable to type 'number'.

8   checkSmsProviderStatus(provider[0], provider[1]);  
                                        ~~~~~~~~~~~

En remplaçant "200" par 200 (expectedStatus), le code compile sans erreur ✅
C'est donc le compilateur qui garantie le bon typage des données de configuration : how cool is it?

Ce n'est pas tout ! Du point de vue de la developer experience, il existe un cas d'utilisation encore plus commun où cette fonctionnalité est très puissante : l'internationalisation d'une application.

 

Traductions

L'utilisation de tableaux associatifs, comme des fichiers de traductions (clé-valeur), accompagné de son exemple capilo-tracté au typage excessif totalement assumé ➡

// fr.json  
{  
  "Il n'a pas dit bonjour": "Il n'a pas dit bonjour",
  "Du coup, son code n'était pas clair": "Du coup, son code n'était pas clair"
}

// en.json  
{  
  "Il n'a pas dit bonjour": "He did not say hello",
  "Du coup, son code n'était pas clair": "So, his code was not clear",
  "Oh, vraiment ?": "O RLY?"

Avec le code exploitant ces fichiers de traductions, découpée en plusieurs étapes :

1 - Les imports et le typage des clés de traductions.

import frLocales from './fr.json';  
import enLocales from './en.json';

// union de toutes les propriétés communes à fr.json et en.json
type TranslationKeys = keyof (typeof frLocales | typeof enLocales);  

resolveJsonModule-i18n-keys

2 - La déclaration des langues et l'association des traductions correspondantes.

type Language = 'en' | 'fr';  
const DEFAULT_LANG: Language = 'fr';

// composition des langues possibles et de leurs traductions
type TranslationsMap = {  
  [l in Language]: {
    // module JSON: ensemble de couples clé-traduction
    [k in TranslationKeys]: string
  }
};
const translations: TranslationsMap = {  
  fr: frLocales,
  en: enLocales,
};

3 - Et l'exploitation de ces initialisations.

function translate(key: TranslationKeys, locale: Language = DEFAULT_LANG) {  
  return translations[locale][key];
}

console.log(translate("Il n'a pas dit bonjour"));  
console.log(translate("Du coup, son code n'était pas clair", 'en'));

// ===>
// Il n'a pas dit bonjour
// So, his code was not clear

resolveJsonModule-autocomplete-i18n

 

Ce code présente quelques inconvénients :

  • Les nouvelles langues ont besoin d'être importées et intégrées manuellement
  • Il va (quelque peu 🙄) corser le code splitting et l'optimisation du lazy loading d'une application frontend

Cependant, il a aussi quelques avantages :

  • Autocomplétion des clés de traduction et des langues disponibles
  • Type safety des clés (et langues) utilisées (une clé supprimée ou modifiée par inadvertance empêchera la compilation du code)
  • Garantie que chaque clé utilisée existe dans chaque fichier de traduction (à moins d'utiliser une intersection (typeof frLocales & typeof enLocales) plutôt qu'une union pour autoriser toutes les clés, comme "Oh, vraiment ?" présente seulement dans en.json)