Dans cette série d’articles, nous aborderons ensemble quelques patterns et techniques pour nous permettre de tirer pleinement parti de Typescript et de l’outillage disponible dans nos IDE. Dans ce premier épisode, nous aborderons un problème d’interpolation de chaînes de caractères. À partir d’un ensemble de messages avec des trous, comment peut-on dériver un objet avec les infos à remplir à partir de l’objet de paramètres qui contient différents messages à trous ?
Implémentation initiale
Tout au long de cet article, nous allons nous baser sur un objet DESCRIPTIONS
, qui contient en clé le code du message, et en valeur son contenu. Nous avons également une fonction fillDescription
qui prend en paramètre le code du message et un objet de paramètres, et remplace dans le message les variables marquées entre accolades.
Ce code est fonctionnel, cependant nous pouvons identifier deux problèmes potentiels:
- Il n'y a aucune validation des clés des messages, rien ne nous empêche de demander la description d'un message
WELCOME
. - Il n'y a pas de validation sur les variables à interpoler. Si on décide dans une évolution future de rajouter des paramètres, ou si on oublie tout simplement de renseigner l'objet en question, les
{variables}
vont se retrouver telles quelles dans le message final. (Bonjour {prenom} {nom})
Ces deux problèmes sont certes vérifiables dans le code, au runtime, mais cela nous obligerait à traiter les cas d'erreur un peu partout.
Ce que l'on aimerait, c'est d'avoir une validation à la compilation des codes des messages, et des paramètres pour chaque message. De plus, cette validation nous facilitera les futurs développements, en nous proposant une autocomplétion de l'objet de paramètres.
Validation de la clé du message
Avant de générer le type pour valider les paramètres du message, commençons par leur clé. Pour pouvoir valider le type des clés, nous allons avoir besoin de trois choses:
as const
sur l’objet DESCRIPTIONS nous permet d’indiquer à typescript que notre objet est une constante (dans le vrai sens du terme)typeof DESCRIPTIONS
: puisque l’objetDESCRIPTIONS
est une vraie constante, typescript est capable de dériver un type objet qui représente exactement notre objetkeyof typeof DESCRIPTIONS
permet d’extraire les clés du type précédent, ce qui nous donne le type"HELLO" | "UPGRADE_SUBSCRIPTION" | "CANCEL_SUBSCRIPTION" | "SHARE_SOCIAL"
. Notons au passage que ce type est nécessaire à la fonctionfillDescription
. Si on essaye de remplacer ce type par string, on a une erreur lors de l’accèsDESCRIPTIONS[code]
. En effet, typescript n’est pas capable de garantir que toutes les chaînes de caractères sont valides.
Inférence de paramètres
Maintenant que nous avons validé statiquement nos clés de messages, passons au messages en eux mêmes.
Pour cela, nous allons besoin de faire des calculs au niveau type, donc pour cela nous allons définir un type paramétré (caractérisé par les chevrons: ExtractParams<T>
).
Dans un premier temps, nous allons essayer de détecter les chaînes de caractères qui ne contiennent uniquement un {placeholder}
. Pour cela nous allons tester si notre type T
est une chaîne de caractère constituée de quelque chose entouré d’accolades. On laisse le compilateur inférer (deviner) le type de quelque chose. Si ce n’est pas le cas, on renvoie un type “objet vide”, sinon on peut construire un type objet avec comme clé notre valeur et en valeur le type string
.
A noter que le type Placeholder
inféré ne peut pas servir directement comme clé dans un type objet: le compilateur n’est pas capable d’assurer que l’on a des chaines uniques "world"
, et non pas de chaines multiples "hello" | "world"
, on est obligé de construire des clés à l’intérieur du type K in Placeholder
.
Nous avons notre première validation via l'inférence de type, mais on veut valider des messages entiers, pas seulement des messages contenant uniquement une clé. Pour cela, nous allons inférer deux parties dans le type, Start
et End
, et extraire récursivement nos paramètres sur le type End
, et construire petit à petit notre type Params
en fusionnant nos types objets. On notera au passage que l’on n’a pas besoin d’extraire les paramètres du type Start
, puisqu’on construit l’objet du début vers la fin
En résumé
Pour rassembler tout ça, nous avons: un moyen d’inférer les types des clés des messages, et un moyen d’inférer les paramètres du contenu des messages.
Il ne nous reste plus qu’à ajuster le type de fillDescription
:
Bonus: rassembler une intersection de types
Vous aurez remarqué que le type inféré par ExtractParams
est un peu difficile à lire:
// Type inféré depuis fillDescription("SHARE_SOCIAL", {...})
function fillDescription<"SHARE_SOCIAL">(code: "SHARE_SOCIAL", params: {
titre: string;
} & {
reseauSocial: string;
}): string
Je vous propose ce petit type utilitaire pour rassembler ces différents types objets en un seul
type Pretty<Obj> = { [K in keyof Obj]: Obj[K] } & {}
Nous avons vu ensemble comment extraire un type depuis un objet via une simple API. Nous aurions pu complexifier le modèle en y rajoutant un espèce de typage, en définissant par exemple des types dans le placeholder {date|toDate}
, ou {price|toEUR}
, mais cela dépasse la portée de cet article.
Dans ce playground final, je vous laisse avec un petit problème. Admettons que nous ayons une fonction updateSubscription
qui a d’abord été codée pour annuler un abonnement, mais que l’on veut étendre à une évolution. On passe donc un booléen pour déterminer quel code utiliser, mais du coup le type inféré n’est pas suffisant: d’après le type de la clé "UPGRADE_SUBSCRIPTION" | "CANCEL_SUBSCRIPTION"
, le type des paramètres est l’union des deux paramètres, mais rien ne nous empêche d’utiliser le premier type de paramètre avec le deuxième code.
Nous avons ici un problème de polymorphisme que nous ne pouvons pas résoudre simplement.
Une approche est de forcer l’intersection des deux objets paramètres, via le type UnionToIntersection
de la bibliothèque utility-types.
Dans un prochain article, nous approfondirons les raisons pour lesquelles ce type est structuré de cette façon particulière et pourquoi il produit les résultats attendus.