TypeScript : Programmation de types

La programmation de types avec TypeScript

Salut tout le monde ! Ça faisait un bail ! Aujourd’hui, on va parler de TypeScript, et à moins d’avoir vécu reclus dans une grotte ces dernières années, il est fort probable que vous ayez déjà eu l’occasion d’utiliser TypeScript, qui s’est imposé comme un élément incontournable du développement web moderne. Mais malgré sa popularité, on remarque que les capacités de son système de types avancés restent encore largement sous-estimées par la plupart des développeurs.

Pourtant, la création de types personnalisés est extrêmement puissante et permet d’améliorer la qualité et l’efficacité de nos développements, elle est même considérée comme un langage de programmation à part entière, une sorte de langage programmation dans le langage de programmation. Bref, si vous voulez profiter pleinement de tout le potentiel de TypeScript, installez-vous confortablement et c’est parti !

Petite mise en bouche

Plongeons directement au cœur du sujet sans perdre de temps avec des futilités. Vous êtes probablement familiers avec les types utilitaires proposés par TypeScript. ça ne vous dit rien ? Pourtant je suis persuadé que vous les avez déjà utilisés. Nous allons en explorer quelques-uns ensemble.

Partial<T>

Le type utilitaire Partial<T> rend toutes les propriétés du type T optionnelles. Par exemple, imaginons que nous avons une interface représentant un utilisateur :

Maintenant, disons que nous voulons créer une fonction pour mettre à jour les informations d’un utilisateur, mais nous ne voulons pas forcer à fournir toutes les propriétés. Au lieu de cela, nous voulons permettre de mettre à jour seulement certaines propriétés. Nous pouvons utiliser le type utilitaire Partial<T> pour y parvenir :

Maintenant, lors de l’appel de la fonction updateUser, nous pouvons fournir uniquement les propriétés que nous voulons mettre à jour :

Pick<T, Keys>

Le type utilitaire Pick<T, Keys> permet de créer un nouveau type en sélectionnant un ensemble spécifique de propriétés à partir du type T. Cela peut être utile lorsque vous souhaitez travailler avec un sous-ensemble de propriétés d’un objet ou d’une interface, sans avoir besoin de toutes les propriétés.

Prenons l’interface représentant un utilisateur que nous avons utilisé précédemment :

Supposons maintenant que nous souhaitons créer une fonction pour afficher un aperçu des informations d’un utilisateur, sans révéler toutes les informations personnelles. Nous pouvons utiliser le type utilitaire Pick<T, Keys> pour cela :

La fonction getUserPreview retourne un objet contenant uniquement les propriétés firstName, lastName et email de l’interface User. Le type utilitaire Pick<T, Keys> nous permet de sélectionner précisément les propriétés que nous voulons extraire d’un type donné. Dans cet exemple, nous extrayons les propriétés firstName, lastName et email du type User.

Omit<T, Keys>

Le type utilitaire Omit<T, Keys> en TypeScript permet de créer un nouveau type en excluant un ensemble spécifique de propriétés à partir d’un type existant. Oui c’est l’inverse de Pick<T, Keys>. On peut donc totalement réecrire l’exemple précédent mais cette fois-ci avec Omit<T, Keys>:

Parameters<T>

Le type utilitaire Parameters<T> permet de déduire le type des paramètres d’une fonction en se basant sur son type. Plus précisément, Parameters<T> extrait le type des paramètres de la fonction à partir du type de la fonction T. Si T est une fonction, alors Parameters<T> renvoie un tuple contenant le type de chaque paramètre de la fonction.

C’est toujours plus explicite avec un exemple :

Dans cet exemple, nous avons défini une fonction sum qui prend deux paramètres de type number. Ensuite, nous avons utilisé Parameters<typeof sum> pour extraire le type des paramètres de la fonction sum, qui est [number, number].

Et encore pleins d’autres !

Évidemment, il existe une foule d’autres types utilitaires en TypeScript qui peuvent rendre la création de types plus simples. Par exemple, Awaited<T> permet de connaître le type de la valeur renvoyée par une promesse, ReturnType<T> permet de déduire le type de la valeur renvoyée par une fonction, et NonNullable<T> permet de créer un nouveau type en excluant les valeurs null et undefined d’un type donné T. Bref, si vous voulez découvrir tous les types utilitaires disponibles, il suffit de jeter un œil à la documentation officielle de TypeScript.

Vous vous rappelez quand je vous ai dit que l’écriture des types en TypeScript ressemble à celle d’un vrai langage de programmation ? Eh bien, c’est exactement ce que nous allons explorer ensemble et voir ce qui se cache derrière la création de types utilitaires.

Un langage de programmation dans le langage de programmation

Le système de type de TypeScript permet d’écrire de vrais algorithmes – certains disent même qu’il est Turing-complet ! Mais bon, on s’en fiche un peu, hein ? Ce qui nous intéresse, c’est de savoir comment écrire des fonctions, des conditions, assigner des variables, même comment écrire des boucles, tout ça avec ce système de types.

Les fonctions

Il est possible d’écrire des fonctions qui agissent sur les types grâce à la syntaxe des génériques :

Décortiquons chaque élément :

  • Fn  est le nom de la fonction ;
  • A et B sont les paramètres ;
  • extends string permet de définir le type du paramètre;
  • = 'world' permet de définir quant à lui une valeur par défaut
  • [A, B] est le corps et le retour de la fonction ;

Prenons un exemple pour illustrer tout ça. Créons une fonction agissant sur les types permettant de concaténer les types de deux tableaux :

Le type Concat prend deux types génériques T et U, qui sont tous deux des tableaux de types inconnus (unknown[]). Le type résultant [...T, ...U] est un tuple qui concatène les éléments de T et U. Les opérateurs de décomposition (...T et ...U) sont utilisés pour extraire les éléments de chaque tableau, puis les combiner ensemble.

Voici un exemple d’utilisation de ce type Concat :

Les branchements conditionnels

Nous pouvons utiliser le mot-clé extends pour vérifier si un type est assignable ou substituable à un autre type, et l’opérateur ternaire ? pour choisir entre deux types en fonction de cette vérification. Par exemple :

Ici, si A peut être assigné ou substitué à B, alors C prendra la valeur de TrueExpression . Sinon C prendra la valeur de FalseExpression.

Illustrons tout ça avec un exemple concret :

Dans cet exemple, nous avons deux interfaces, Admin et User, et un type AppUser qui est l’union de ces deux interfaces.

Nous avons ensuite le type AccessLevel<T>, qui utilise un branchement conditionnel pour déterminer le niveau d’accès à accorder en fonction du type d’utilisateur fourni. Si T est de type Admin, alors AccessLevel<T> sera de type 'Accès total'. Sinon, il sera de type 'Accès limité'.

Nous créons ensuite deux objets de type Admin et User, et utilisons le type AccessLevel pour déterminer le niveau d’accès approprié pour chaque type d’utilisateur.

L’assignation de variables

Nous avons souvent besoin d’assigner le résultat d’une expression à une variable pour l’utiliser ultérieurement. Il est tout à fait possible de réaliser la même chose avec le système de types en utilisant les mots clés extends et infer. Pour illustrer cela, réécrivons le type utilitaire ReturnType<T> qui permet de récupérer le type de retour d’une fonction :

Je vais décomposer ce type pour vous aider à mieux le comprendre :

  • T extends Function : Ceci vérifie si le type générique T est une fonction.
  • T extends (...args: any[]) => infer R : Cette partie vérifie si T est une fonction avec un nombre indéterminé d’arguments (représentés par ...args: any[]) dont les types sont inconnus. Le mot-clé infer est utilisé pour déduire le type de retour R de cette fonction.

  • ? R : never : Ceci est une expression conditionnelle qui renvoie R (le type de retour déduit) si T est effectivement une fonction avec un type de retour déductible. Sinon, il renvoie never, ce qui signifie qu’il n’y a pas de type de retour valide.

Voici un exemple concret d’utilisation :

Les boucles

Il existe diverses méthodes pour utiliser les boucles lorsque nous manipulons des types, et chacune dépend de la structure de données sur laquelle nous opérons, qu’il s’agisse d’objets, de tuples ou d’unions.

Parcourir les types objets avec les types mappés

Lorsque nous travaillons avec des types d’objets, nous pouvons utiliser les types mappés pour parcourir leurs propriétés. Ils permettent de créer un nouveau type en appliquant une transformation aux propriétés d’un type existant.

Prenons un exemple avec le type OrNull<T> :

Dans cet exemple, nous utilisons un type mappé pour parcourir les propriétés de T et appliquer une transformation. Le type OrNull<T> prend un type T en entrée et crée un nouveau type avec les mêmes propriétés que T, mais dont les valeurs sont de type T[K] | null.

L’expression [K in keyof T] signifie que pour chaque clé K dans le type T, l’objet résultant aura une propriété avec cette clé. T[K] représente le type de la valeur associée à la clé K dans le type T.

Ainsi, lorsque nous utilisons OrNull<{ a: number; b: string }>, nous obtenons le type { a: number | null; b: string | null }.

Les types mappés sont puissants car ils préservent la relation entre la clé et la valeur pour chaque propriété de l’objet. De cette façon, nous pouvons appliquer des transformations complexes sur les types d’objets tout en conservant la structure de l’objet d’origine.

Examinons un autre exemple. Supposons que nous souhaitions créer un type utilitaire qui filtre les propriétés d’un certain type dans un objet. Voici comment cela pourrait être fait avec un type mappé :

Le type générique FilterProp<T, F> prend en entrée deux types : T, qui est un objet, et F, qui est le type que nous voulons filtrer. Le type mappé parcourt les propriétés de T et vérifie si le type de la valeur de chaque propriété (T[K]) est un sous-type de F. Si c’est le cas, la propriété est incluse dans le nouvel objet avec le type F. Sinon, la propriété est exclue du nouvel objet en utilisant le type never.

Voici un exemple pour illustrer comment utiliser ce type :

Dans cet exemple, nous avons un type User avec quatre propriétés. En utilisant le type utilitaire FilterProp<User, number>, nous créons un nouveau type FilteredUser qui ne contient que les propriétés de type number de l’objet User.

Parcourir les tuples avec les types conditionnels récursifs

Lorsque nous travaillons avec des tuples, nous pouvons utiliser la récursion pour itérer sur les éléments du tuple. Les types conditionnels récursifs sont une fonctionnalité puissante de TypeScript qui nous permet d’appliquer des transformations complexes sur les tuples.

Voici un exemple avec le type Includes<Item, List> qui vérifie si un élément Item est présent dans le tuple List :

Voici une explication étape par étape :

  • List extends [infer Head, ...infer Tail] : Ici, nous vérifions si List est un tuple non vide. Si c’est le cas, nous utilisons infer pour extraire la tête (Head) et la queue (Tail) du tuple ;

  • Head extends Item : Nous vérifions si l’élément Head est du même type que Item. Si c’est le cas, nous avons trouvé l’élément recherché, donc nous renvoyons true. Sinon, nous passons à l’étape suivante ;

  • Includes<Item, Tail> : Si Head n’est pas du même type que Item, nous continuons la récursion avec la queue Tail du tuple pour vérifier si Item est présent parmi les éléments restants.

Maintenant, prenons les exemples suivants :

Dans le premier exemple, nous utilisons le type Includes pour vérifier si l’élément 1 est présent dans le tuple [1, 2, 3]. Le résultat est true, car l’élément 1 est effectivement présent dans le tuple.

Dans le second exemple, nous utilisons le type Includes pour vérifier si l’élément 4 est présent dans le tuple [1, 2, 3]. Le résultat est false, car l’élément 4 n’est pas présent dans le tuple.

Voici un autre exemple avec le type OnlyStrings<List> :

Dans cet exemple, le type OnlyStrings<List> extrait uniquement les éléments de type string du tuple List. Encore une fois, nous utilisons la récursion pour parcourir les éléments du tuple et construire un nouveau tuple avec les éléments qui sont de type string.

Parcourir les unions avec les branchements conditionnels

L’utilisation de branchements conditionnels avec le mot-clé extends permet de mapper chaque élément de l’union.  Pour illustrer cette technique, prenons un exemple où nous souhaitons transformer une union de types en un objet.

Le type générique UnionToObject prend deux paramètres : Key, qui doit être une chaîne de caractères, et U, qui est une union. Ainsi, le type UnionToObject vérifie si U est un sous-type de any (ce qui est toujours vrai) dans ce cas, un objet avec des propriétés calculées à partir du type Key et des valeurs de type U est renvoyé. La syntaxe [K in Key]: U  indique que pour chaque clé K dans le type Key, l’objet résultant aura une propriété avec cette clé et une valeur de type U.

Prenons un exemple concret pour illustrer l’utilisation de ce type. Supposons que nous avons des utilisateurs avec des rôles spécifiques tels qu’”admin”, “moderator” et “member”. Nous souhaitons créer un objet associant une propriété role à chaque rôle utilisateur.

Tout d’abord, définissons le type de rôle utilisateur :

Ensuite, utilisons notre type utilitaire UnionToObject pour créer un type représentant un objet avec une propriété role contenant les rôles utilisateur :

Le type UserWithRole est maintenant une union d’objets qui ressemble à ceci :

Grâce à ce type, nous pouvons créer des objets pour différents utilisateurs avec leur rôle respectif. Par exemple :

Bonus : Les types de littéraux de gabarits

En guise de bonus, je vais vous parler des types de littéraux de gabarits. Qu’est-ce que c’est ? En réalité, il s’agit simplement de l’application des chaînes de caractères de gabarit (template strings) aux types. Concrètement, à quoi cela sert-il ? Pour répondre à cette question, prenons un exemple. Imaginons que nous voulons nous assurer que la syntaxe des propriétés d’un type représentant des dimensions est correcte :

Dans cet exemple, nous utilisons des types de littéraux de gabarits pour définir les propriétés width et height de l’interface Dimensions. Cela nous permet de nous assurer que les valeurs de ces propriétés sont des chaînes de caractères contenant un nombre suivi de “px”. Lorsque nous essayons d’assigner une valeur incorrecte à la propriété height, TypeScript détecte et nous signale l’erreur.

Pour finir…

La création de types personnalisés est extrêmement puissante et permet d’améliorer la qualité et l’efficacité de nos développements, nous venons de voir dans cet article les outils pour l’écriture de ceux-ci. Maintenant, place à l’action ! Rendez-vous sur le dépôt Github “type-challenges” pour vous lancer dans des défis allant du plus facile au plus corsé. Vous allez pouvoir appliquer tout ce que vous avez appris. On se retrouve bientôt pour un nouvel article, mais en attendant, souvenez-vous : la curiosité est la meilleure des qualités quand on est développeur !

 

 


Annonces partenaire

Je suis lead developer dans une boîte spécialisée dans l'univers du streaming/gaming, et en parallèle, je m'éclate en tant que freelance. Passionné par l'écosystème JavaScript, je suis un inconditionnel de Node.js depuis 2011. J'adore échanger sur les nouvelles tendances et partager mon expérience avec les autres développeurs. Si vous avez envie de papoter, n'hésitez pas à me retrouver sur Twitter, m'envoyer un petit email ou même laisser un commentaire.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.