Aller au contenu
FrontBack

Typescript Toolbox: Interpolation typée

Dans cette série d'articles, nous aborderons des techniques pour tirer parti de Typescript et de l'outillage disponible dans nos IDE. Dans ce premier épisode, nous abordons un problème d'interpolation de chaînes de caractères.

Photo by Dean's Photo / Unsplash

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.

const DESCRIPTIONS: Record<string, string> = {
    HELLO: "Bienvenue {nom}",
    UPGRADE_SUBSCRIPTION: "Demande de changement d'abonnement {abonnementSource} vers {abonnementCible} à partir du {date}",
    CANCEL_SUBSCRIPTION: "Demande d'arrêt de l'abonnement {abonnement} à partir du {date}",
    SHARE_SOCIAL: "Partager l'article {titre} sur {reseauSocial}",
};

/**
 * Remplissage de contenu d'un message
 * @param code le code du message à remplir
 * @param params les différents paramètres du message
 * @returns un message complet
 */
export function fillDescription(
    code: string,
    params: Record<string, string>
) {
    // interpolate params
    const message = Object.entries<string>(params).reduce<string>(
        (msg, [key, value]) => msg.replace(`{${key}}`, value),
        DESCRIPTIONS[code]
    );
    return message;
}

Implémentation initiale. Lien vers le playground typescript

Ce code est fonctionnel, cependant nous pouvons identifier deux problèmes potentiels:

  1. Il n'y a aucune validation des clés des messages, rien ne nous empêche de demander la description d'un message WELCOME .
  2. 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.

Exemple du code final voulu. La fonction fillDescription est capable de valider le code d'erreur, et on peut autocompléter l'objet params

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’objet DESCRIPTIONS est une vraie constante, typescript est capable de dériver un type objet qui représente exactement notre objet
  • keyof 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 fonction fillDescription. Si on essaye de remplacer ce type par string, on a une erreur lors de l’accès DESCRIPTIONS[code]. En effet, typescript n’est pas capable de garantir que toutes les chaînes de caractères sont valides.
const DESCRIPTIONS = {
    HELLO: "Bienvenue {nom}",
    UPGRADE_SUBSCRIPTION: "Demande de changement d'abonnement {abonnementSource} vers {abonnementCible} à partir du {date}",
    CANCEL_SUBSCRIPTION: "Demande d'arrêt de l'abonnement {abonnement} à partir du {date}",
    SHARE_SOCIAL: "Partager l'article {titre} sur {reseauSocial}",
} as const;

type DescriptionsType = typeof DESCRIPTIONS
type DescriptionsCode = keyof DescriptionsType

/**
 * Remplissage de contenu d'un message
 * @param code le code du message à remplir
 * @param params les différents paramètres du message
 * @returns un message complet
 */
export function fillDescription(
    code: DescriptionsCode,
    params: Record<string, string>
) {
    // interpolate params
    const message = Object.entries<string>(params).reduce<string>(
        (msg, [key, value]) => msg.replace(`{${key}}`, value),
        DESCRIPTIONS[code]
    );
    return message;
}

Implémentation permettant la validation des codes des messages. Lien vers le playground typescript

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.

const SINGLE = "{world}" as const;

type ExtractParams<T> = T extends `{${infer Placeholder}}`
    ? { [K in Placeholder]: string }
    : {}

const extracted: ExtractParams<typeof SINGLE> = {
    world: ""
}

const num: ExtractParams<number> = {}

Parsing de chaine de caractère contenant uniquement une variable via l'inférence typescript. Lien vers le playground typescript

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

const SINGLE = "Bonjour {world} !" as const;

type ExtractParams<T> = T extends `${infer Start}{${infer Placeholder}}${infer End}`
    ? { [K in Placeholder]: string } & ExtractParams<End> // Remplacer string par { start: Start, end: End } pour voir les types inférés
    : {}

const extracted: ExtractParams<typeof SINGLE> = {
    world: ""
}

const num: ExtractParams<number> = {}

Parsing de chaine plus complexe via l'inférence typescript. Lien vers le playground typescript

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 :

/**
 * Remplissage de contenu d'un message
 * @param code le code du message à remplir
 * @param params les différents paramètres du message
 * @returns un message complet
 */
export function fillDescription<Code extends DescriptionsCode>(
    code: Code,
    params: ParseParams<DescriptionsType[Code]>
) {
    // interpolate params
    const message = Object.entries<string>(params).reduce<string>(
        (msg, [key, value]) => msg.replace(`{${key}}`, value),
        DESCRIPTIONS[code]
    );
    return message;
}

fillDescription("SHARE_SOCIAL", {
    titre: "",
    reseauSocial: ""
})

Implémentation finale, avec validation des clés et de l'objet paramètres. Lien vers le payground typescript

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.

Dernier