TypeScript : résolution des modules JSON
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 dansmessagebird.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
😍 :
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);
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
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 dansen.json
)
Retrouvez-moi sur Twitter :
@VinceOPS