Romain Durand

🎅 Advent of TypeScript 2024 - Jour 12

Le 12 décembre 2024 à 12:34

🎩Bernard's Long List Of Names - Un .map() dans le système de types ?

https://www.adventofts.com/events/2024/12

Aujourd'hui on poursuit la trajectoire du jour 11 et on continue à transformer les types de données !

On nous demande de transformer le type d'un tableau d'éléments en un type de tableau de même taille, mais dont on changerait le format des éléments. Initialement, les éléments sont des tuples [string, string, string], où la première représente un prénom, la deuxième le genre de ce prénom (mais on ne s'en sert pas dans cet exercice), et la troisième représente le nombre d'enfants portant ce prénom, mais également au sein d'une string.

On doit transformer ces éléments en objets {name: "prénom", count: number, rating: "naughty" | "nice"}, où on conserve le prénom original, on transforme la string représentant le nombre en un vrai nombre, et on ajoute un rating déterminé en fonction du nombre de lettres du prénom.

Il y aura donc 3 sous-problèmes à résoudre :

  • transformer le type du tableau
  • réussir à déterminer le rating: 'naughty' ou 'nice'
  • transformer le count de string à number

Solution

Commençons par le plus simple.

Transformer un littéral string en littéral number

Ici on utilise infer, qui ne peut-être utilisé qu'au sein d'un type conditionnel, pour demander si on peut récupérer un littéral numérique au sein du littéral string passé en paramètre de type. On ajoute extends number après le infer N pour récupérer le number contenu dans la string si il existe.

On retourne N si on arrive à extraire du paramètre de type S, un type N qui corresponde à la contrainte extends number, et never dans le cas contraire.

type StringToNumber<S> = S extends `${infer N extends number}` ? N : never;

type test = StringToNumber<"1312">;
type test = 1312
type test2 = StringToNumber<"not a number">
type test2 = never

Déterminer la valeur de la propriété rating

De l’énoncé, on sait que

« a child is naughty or nice based on the number of characters in their name! »

Et dans les test, on peut lire :

// even number of characters in the name get 'naughty'
// odd number of characters in the name get 'nice'

Ma stratégie va être ici de partir de la valeur 'naughty', et de la faire alterner entre 'naughty' et 'nice' à chaque itération, dans laquelle on retirera une lettre au prénom, jusqu'à ce qu'il n'y ait plus de lettre à retirer. Le cas simple pour voir qu'il faut bien partir de la valeur 'naughty' est de se représenter le cas avec un prénom d'une seule lettre (nombre de lettre impair, donc 'nice') :

  • on retire la lettre
  • on change la valeur de 'naughty' à 'nice'
  • il n'y a plus de lettre
  • on retourne donc 'nice'

De la même manière, pour un prénom de 2 lettre (pair, donc 'naughty'), on alternerait 2 fois pour revenir à cette valeur initiale.

Qui dit itération dans le système de types, dit type récursif.

Pour les paramètres de type, on a besoin du Name : la string qui représente le prénom, et d'un deuxième Rating pour stocker la valeur de retour. On définit au passage la valeur de départ pour Rating

type NaughtyOrNice<Name extends string, Rating extends string = 'naughty'> = ...

Ensuite on utilise deux infer au sein d'un type conditionnel pour vérifier si on peut retirer un caractère au prénom. Si oui, on alterne la valeur de Rating, puis on fait la récursion, sinon on retourne la valeur de Rating. le type _ représente la lettre que l'on essaye de retirer, et Rest représente le reste du prénom.

Name extends `${infer _}${infer Rest}`
  ? // alternance de Rating + récursion
  : Rating;

Pour faire alterner la valeur de Rating, on peut utiliser un autre type conditionnel.

Rating extends 'naughty'
  ? 'nice'
  : 'naughty'

Sauf qu'on ne souhaite pas retourner directement la valeur, on veut faire de la récursion avec cette nouvelle valeur. On appelle donc NaughtyOrNice avec Rest en premier paramètre, soit ce qu'il reste du prénom après cette itération, et 'naughty' ou 'nice' en 2e paramètre.

Rating extends 'naughty'
  ? NaughtyOrNice<Rest, 'nice'>
  : NaughtyOrNice<Rest, 'naughty'>

Si on récapitule, on obtient ça :

type NaughtyOrNice<Name extends string, Rating extends string = 'naughty'> = Name extends `${infer _}${infer Rest}`
  ? Rating extends 'naughty'
    ? NaughtyOrNice<Rest, 'nice'>
    : NaughtyOrNice<Rest, 'naughty'>
  : Rating;

type test1 = NaughtyOrNice<'Bachar'>;
type test1 = "naughty"
type test2 = NaughtyOrNice<'Keanu'>;
type test2 = "nice"

On voit que le type fonctionne comme prévu.

Transformer le type du tableau

Ce qui est demandé ici ressemblerait à un .map(...) en javascript. Mais il n'existe pas d'équivalent dans le système de type. Le premier réflexe serait de tenter de le transformer par itérations, avec un type récursif. Mais quand on regarde la taille du tableau, qui contient plus de 30 000 entrées, on sait, si on a déjà un peu joué avec des types récursifs, que ce n'est pas une option viable. TypeScript a une limite pour la récursion, la dernière fois que je l'ai atteint, je crois que c'était à peu près 1000. Donc content d'avoir eu ce problème pour ne pas m'embarquer dans une solution de récursion pas viable.

Ma 2e idée vient du javascript dans lequel on peut utiliser du duck typing, à savoir :

« Si je vois un oiseau qui vole comme un canard, cancane comme un canard, et nage comme un canard, alors j'appelle cet oiseau un canard »

Appliqué ici, il s'agit de considérer qu'un tableau n'est qu'un objet comme un autre, dont les clefs sont des nombres.

On peut donc utiliser du un type mappé pour transformer chacune des valeurs associées à ces clefs numériques.

Commençons par extraire les valeurs qui nous intéressent dans chacun des éléments des tableaux avec des infer, donc au sein d'un type conditionnel. On ne récupère que la première (Name) et troisième valeur (Count) de chaque élément. Pour les paramètres de type, on peut pour le moment se contenter d'un type N sans contraintes. Rien de particulier pour le reste du type mappé, on se contente de parcourir les clefs.

type FormatNames<N> = {
  [K in keyof N]: N[K] extends [infer Name extends string, infer _, infer Count]
    ? // TODO
    : never;
}

Enfin on peut compléter avec la valeur de retour attendue, à savoir un objet avec les clefs name, count et rating, et leurs valeurs associées. Pour name, on vient d'infer Name, pour count on a créé StringToNumber, et pour rating, on a NaughtyOrNice

type FormatNames<N extends [string, string, string][]> = {
  [K in keyof N]: N[K] extends [infer Name extends string, infer _, infer Count]
    ? {
      name: Name;
      count: StringToNumber<Count>;
      rating: NaughtyOrNice<Name>;
    }
    : never;
};

C'est presque bon, il ne reste plus qu'un test à valider, c'est que le type retourné par FormatNames ait également une propriété length qui contienne la taille du tableau, tout comme le type du tableau qu'il transforme. Pour ça, on peut se contenter d'une intersection avec un autre objet & { length: N['length'] } où on définit cette propriété length, avec comme valeur, la propriété length du tableau N passé en paramètre.

Seulement pour le moment N n'est pas forcément un tableau, et donc TypeScript dit que la propriété length n'existe pas forcément sur N. Pour corriger ça, on peut ajouter une contrainte au paramètre de type N avec extends [string, string, string][], puisqu'on sait que chaque élément est un tuple de trois string, sinon on peut continuer dans l'idée du duck typing et simplement dire que T a une propriété length qui est un number avec extends { length: number }.

Avec ça, tous les tests passent !

type FormatNames<N extends [string, string, string][]> = {
  [K in keyof N]: N[K] extends [infer Name extends string, infer _, infer Count]
    ? {
      name: Name;
      count: StringToNumber<Count>;
      rating: NaughtyOrNice<Name>;
    }
    : never;
} & { length: N['length'] };

type test1 = FormatNames<[['Keanu', 'M', '123'], ['Bachar', 'M', '456']]>;
type test1 = [{ name: "Keanu"; count: 123; rating: "nice"; }, { name: "Bachar"; count: 456; rating: "naughty"; }] & { length: 2; }

À demain !