Chez Upsun, nous avons travaillé sur la conversion de notre tableau de bord client (Console) de JavaScript à TypeScript. En théorie, c'est aussi simple que d'ajouter quelques paquets, d'apporter une petite modification à notre script de construction et de changer des milliers d'extensions de fichiers. Mais comme le dit le proverbe : en théorie, il n'y a pas de différence entre la théorie et la pratique. En pratique, il y en a une.
Cela fait maintenant plus d'un an et demi que nous avons entamé ce processus de conversion à TypeScript, et nous avons beaucoup appris en cours de route, en développant un grand nombre de nos propres stratégies. Nous savions dès le départ qu'il s'agirait d'une entreprise monumentale et la plupart des défis auxquels nous avons été confrontés étaient prévisibles, mais nous avons également rencontré quelques obstacles d'une complexité inattendue en cours de route. Et bien que le travail ne soit pas encore terminé, nous récoltons déjà les fruits de la détection des bogues plus tôt dans le processus de développement et d'une confiance accrue dans notre code.
Toute application raisonnablement complexe (développeurs multiples, clients, paiements, etc.) bénéficiera de la même conversion. Ainsi, si vous envisagez une migration similaire, notre expérience peut vous aider à parcourir le chemin plus facilement.
Pour un projet JavaScript (JS) existant, la première étape consiste simplement à faire fonctionner TypeScript (TS) avec votre base de code. TS ne peut pas être exécuté directement dans les navigateurs, une conversion en JavaScript est donc nécessaire. Les spécificités de cette opération dépendront de votre configuration, mais si vous avez déjà une étape de construction dans votre pipeline de déploiement, il ne devrait pas être difficile de l'ajouter. Les projets hébergés par Upsun ont accès à des hooks qui peuvent automatiser ce processus, rendant le processus de conversion encore plus facile.
D'une manière générale, ce processus manuel implique l'ajout du paquet TS (et d'autres paquets de support pour le linting, les tests, etc) à votre projet et la configuration de votre bundler pour gérer les fichiers TS. Pour la console Upsun, cela nécessite l'ajout et la modification de quelques règles dans notre config Webpack ; pour d'autres bundlers, le processus peut être encore plus simple. Vous devrez également ajouter un fichier de configuration pour TS afin de lui indiquer votre projet et vos préférences.
La principale décision à prendre lors de la configuration de TS est de savoir à quel point vous voulez qu'il soit strict - plusieurs paramètres ont un impact sur ce point. S'ils sont désactivés, votre code TS fonctionnera essentiellement comme JS ; c'est-à-dire que les informations de base sur les types seront déduites, mais vous ne profiterez pas de toute la puissance du langage.
Commencer avec cette approche plus souple réduirait la friction initiale de la conversion des fichiers en TS, mais cela comporte des inconvénients significatifs. En utilisant une approche stricte dès le départ, le compilateur - et votre environnement de développement intégré - ne se contentera pas de garder une trace des types, mais indiquera également quand et où il a besoin que des informations sur les types soient spécifiées. Cela peut demander un peu plus d'efforts au départ, mais si vous attendez que tout le code ait été migré avant d'activer ces commutateurs, vous serez accueilli par un énorme rapport d'erreurs qui peut être accablant.
Une fois que TS a été mis en place et qu'une approche initiale a été choisie, la prochaine décision décourageante est de savoir où commencer la conversion. Nous avons eu du succès avec deux philosophies différentes. La première consiste à identifier et à migrer les fichiers feuilles. Il s'agit de fichiers qui ne dépendent d'aucun autre code. Ils n'importent que des bibliothèques externes, voire aucune. Le compilateur TS peut déduire certains types des fichiers JS, mais tout ce qui dépasse les bases absolues ne sera pas typé, plus précisément, il aura le redoutable type any
- plus d'informations à ce sujet ci-dessous. Si un fichier TS ne dépend pas du code JS - ce qui sera le cas pour ces fichiers - vous n'aurez pas à vous lancer dans la recherche de types complexes sans l'aide du compilateur et à ajouter manuellement des types qui seront déduits automatiquement par la suite. Les composants simples de l'interface utilisateur et les fonctions utilitaires entrent souvent dans cette catégorie.
L'autre approche de conversion que nous avons utilisée consiste à convertir des fichiers sur lesquels reposent de nombreux autres fichiers. Nous savons que les informations relatives à leur type seront largement utilisées, donc plus tôt nous nous assurerons qu'elles sont exactes, plus les conversions seront faciles. Les exemples incluent les magasins de données (par exemple Redux) et la communication API. Ces fichiers centraux peuvent importer d'autres fichiers locaux, vous devrez donc décider de convertir ces fichiers dès le début du processus ou de les reporter à plus tard. Notre principe directeur a été de trouver des points d'arrêt logiques afin que chaque unité de travail reste relativement autonome.
De l'autre côté de la barrière, il y a les paquets externes. Même les fichiers feuilles peuvent les importer. De plus en plus, les paquets communautaires incluent directement les informations de type, mais beaucoup ne le font pas encore. Dans ce cas, il y a deux options. De nombreux paquets populaires ont des types externes disponibles via Definitely-Typed. Ces types ne sont pas toujours parfaitement synchronisés avec le code réel, mais ils sont généralement tout à fait adéquats et constituent une partie merveilleuse de la communauté open-source.
Occasionnellement, il se peut que vous utilisiez une bibliothèque moins courante qui n'a pas d'informations sur les types disponibles. Dans ce cas, vous pouvez facilement fournir vos propres types. Pour la console Upsun, nous les stockons dans ./types
et les indiquons au compilateur avec cette configuration :
{ "compilerOptions" : { "paths" : { "*" : ["types/*"] } } }
Les types existants peuvent également être remplacés par le même mécanisme, et vous pouvez contribuer à la communauté par le biais de Definitely-Typed.
Il serait réducteur de dire que la meilleure façon d'ajouter des informations sur les types est de ne pas en ajouter du tout, mais le fait est que le compilateur TS est très puissant et a généralement raison de déduire les types. Dans la mesure du possible, nous le laissons faire son travail et ne spécifions manuellement les types qu'en cas de nécessité. Ces situations peuvent être classées en deux catégories :
La première est celle où l'information sur le type n'existe pas, par exemple, les arguments de fonction, l'arg initial Array.reduce
, les réponses de l'API et JSON.parse
. Dans chacun de ces cas, nous devons dire au compilateur à quoi il a affaire. Dans ces cas, nous sommes entièrement responsables de l'exactitude des données. Le compilateur nous alertera s'il pense que le type fourni n'a pas de sens, mais sinon, il nous fait confiance pour savoir ce que nous faisons.
L'autre catégorie consiste à spécifier un type en tant que contrat. Si un type est modifié - par exemple, en changeant ce qu'une fonction renvoie - vous pouvez ne pas vous en rendre compte jusqu'à ce qu'une erreur soit découverte en aval, ce qui peut être plusieurs importations plus loin, si même une erreur est lancée. Pour éviter ce genre de situation, nous spécifions parfois manuellement le type qu'une fonction est censée renvoyer - même s'il peut être facilement déduit - de sorte que si le type de retour change, une erreur est garantie d'être levée, et elle le sera à l'endroit même où le changement a été effectué.
TypeScript apporte des avantages mesurables aux applications JS complexes. Cependant, il présente quelques caractéristiques faciles à utiliser à mauvais escient. Il convient de noter que ces fonctionnalités ne sont pas purement diaboliques et que nous les utilisons occasionnellement, mais elles doivent être bien comprises et n'être utilisées qu'en cas de réelle nécessité.
Le premier est le type any
. any
était un élément incontournable du système de types, destiné à représenter tous les types possibles. En pratique, il désactive la vérification de type et tous les avantages qui en découlent. Nous pouvons maintenant le remplacer en grande partie par le type unknown
. unknown
continue à vérifier les types, mais ne fait aucune hypothèse sur le type sous-jacent. Pour effectuer une opération sur un type inconnu
, nous devons d'abord prouver qu'il est valide. Il est encore rare que nous ayons recours à any
, mais cela soulève toujours la question de savoir s'il n'y a pas une meilleure option.
L'opérateur as
est un élément essentiel du langage, mais c'est au développeur qu'incombe la responsabilité de la précision. Il est utilisé pour convertir un type en un autre. Là encore, le compilateur se plaindra s'il estime que la conversion n'a pas de sens - que les types ne se chevauchent pas suffisamment - mais il est possible de s'en sortir en moulant d'abord en tant qu'inconnu
. Comme pour tout
, il y a des moments où cela est nécessaire, mais nous n'y avons recours que lorsqu'il n'y a pas d'autre option.
Le dernier point n'est pas une caractéristique, mais il faut s'en méfier : la complexité. Le système de types est incroyablement puissant, mais il est possible d'implémenter des choses difficiles à comprendre - à la fois pour les autres et pour votre futur moi. Les types conditionnels, les types mappés et les types récursifs peuvent entrer dans cette catégorie. Comme pour les mises en garde ci-dessus, cela est souvent inévitable dans le processus de conversion vers TS, nous essayons donc d'isoler et de gérer soigneusement cette complexité. Lorsque cela est possible, nous implémentons un type complexe en tant qu'utilitaire de boîte noire. Nous essayons de définir l'espace du problème et les contraintes dès le départ afin que le code fasse bien une chose et n'ait pas à être mis à jour fréquemment, voire jamais. Des commentaires minutieux et réfléchis permettent également de se préparer à tout changement futur.
Pour conclure ce billet, nous allons élargir notre vision et examiner l'impact d'une telle migration sur d'autres aspects du développement, en commençant par le flux de travail Git.
La première étape de la conversion d'un fichier de JS à TS est de changer l'extension du fichier. Git est généralement capable de reconnaître qu'il s'agit toujours du même fichier, et tout l'historique du fichier est conservé. Cependant, si trop de modifications sont apportées au fichier renommé avant qu'il ne soit livré, Git ne le verra plus comme le même fichier. Au lieu d'une opération de renommage, cela sera perçu comme la suppression de l'ancien fichier et l'ajout d'un fichier entièrement nouveau. Le pire est que l'historique du fichier sera perdu dans le processus. Pour contourner ce problème, nous procédons en deux étapes : la première ne modifie que l'extension des fichiers concernés. Nous validons ensuite les fichiers renommés.
Si vous exécutez localement des vérifications de type ou de linting lors de la pré-commission, vous devrez peut-être les contourner ici, car les fichiers renommés ne passeront peut-être pas ces vérifications. Cela peut être fait avec l'option -no-verify
de Git commit, qui ne lancera pas le hook de pré-commission. Une fois que les fichiers renommés ont été validés, nous ajoutons des types là où c'est nécessaire et corrigeons les erreurs qui sont apparues.
Souvent, l'ajout d'informations de type à un fichier nécessitera des modifications dans d'autres fichiers, à la fois ceux qu'il importe et ceux qui l'importent. Cela peut être dû à des bogues préexistants qui n'étaient pas visibles auparavant. La nécessité de gérer les indéfinis de
manière explicite est une situation que nous rencontrons fréquemment. Si vous utilisez la bibliothèque prop-types
pour les composants JS React, elle sera remplacée par les types TS, qui sont généralement beaucoup plus granulaires, et ce changement nécessitera parfois d'autres modifications.
Il peut être difficile d'identifier tous ces changements avant de commencer une migration individuelle, de sorte que la portée d'un changement peut s'étendre au fur et à mesure que vous avancez. Là encore, une approche judicieuse est nécessaire pour trouver les bonnes limites pour une migration donnée, et nous constatons souvent qu'il est nécessaire de diviser une migration en deux ou plusieurs.
Vous pouvez également découvrir des problèmes sous-jacents dans votre code au cours du processus qui ne sont même pas liés aux informations de type. D'après notre expérience, plutôt que d'effectuer ce travail dans le cadre d'une migration, il peut être préférable de le corriger d'emblée et de le livrer séparément afin que l'étendue du travail de migration reste relativement faible. En fin de compte, cela signifie que nous avons souvent jusqu'à quatre étapes pour chaque migration :
En ce qui concerne ce dernier point, il se peut que vous deviez adapter vos autres outils de développement pour prendre en charge TS. Si vous utilisez eslint, vous voudrez probablement ajouter @typescript-eslint/parser
et @typescript-eslint/eslint-plugin
et configurer des règles lint supplémentaires qui s'exécutent sur vos fichiers TS. Certaines d'entre elles tirent même parti de la vérification de type et fournissent un excellent complément à la vérification d'erreur fournie par le compilateur TS.
Il se peut que vous deviez également apporter quelques modifications à votre programme de test afin qu'il prenne en charge les fichiers TS. Jest exige que TS soit transpilé en JS avant d'être exécuté, mais Vitest supporte TS dès le départ. Le problème le plus difficile lorsqu'il s'agit d'écrire des tests en TS est que le typage n'a pas besoin d'être aussi rigoureux que votre code de production, et il n'est pas pratique de le soumettre aux mêmes normes. Par exemple, le besoin de simuler un type complexe entier parce qu'une fonction l'attend, même si un test n'est concerné que par une partie de ce type.
Dans ce cas, il est beaucoup plus probable que nous nous appuyions sur des astuces rapides et sales comme le typecasting avec as
. Il est également possible de créer des surcharges de configuration pour le TS, eslint et de nombreux autres outils spécifiques aux tests. Vous devez toujours vous assurer que vous n'utilisez pas un hack pour masquer un problème légitime, mais c'est un compromis intéressant pour éviter d'avoir à taper strictement vos tests.
Pour une entreprise aussi importante que la conversion de l'ensemble de votre base de code, l'idéal serait de pouvoir interrompre tous les autres travaux jusqu'à ce que la migration soit terminée. Malheureusement, c'est rarement le cas. Cela dit, si possible, il est avantageux de pouvoir donner la priorité à une refonte majeure à la fois. Les nouvelles fonctionnalités, les corrections de bogues et les autres tâches quotidiennes devront toujours être prises en compte, mais la réalisation simultanée d'autres projets de grande envergure entraînera une croissance exponentielle de la complexité globale.
Nous avons également appris à minimiser les changements fonctionnels lors de l'ajout de types. En réalisant une étude approfondie de l'ensemble de votre base de code, vous rencontrerez sans aucun doute une dette technique et d'autres problèmes qu'il serait bon de corriger. Il est préférable de les documenter et de les reporter à une date ultérieure, faute de quoi la portée et la complexité deviendront incontrôlables, de même que le temps nécessaire à la conversion complète. En général, nous essayons d'isoler des parties gérables de changements qui peuvent être rapidement transformés en TS. Ils sont plus faciles à comprendre et à réviser et donneront lieu à moins de conflits de fusion.
Lorsque nous avons commencé ce processus au début de l'année 2022, nous avions un peu plus de 100 000 lignes de code JS pour Console. Un an et demi plus tard, nous en sommes à environ 70 000 lignes de code JS et TS. Nous avons également ajouté de nombreuses fonctionnalités en cours de route, et ce n'est pas fini ! À ce stade, tous nos nouveaux développements commencent en TS.
Il y a eu des défis à relever en cours de route. Nous avons dû faire interagir tous nos outils existants avec un nouveau langage, former l'équipe à l'utiliser et accepter que des choses qui étaient faciles - ou facilement ignorées - en JS puissent maintenant nécessiter une approche plus stricte. Mais les avantages l'ont largement emporté sur les inconvénients. Nous avons davantage confiance dans le code que nous exécutons dans TS. Nous avons trouvé et corrigé de nombreuses lacunes dans notre ancien code. Nous pouvons détecter les problèmes beaucoup plus tôt dans le processus de développement, généralement avant qu'ils n'atteignent la production.
Une migration de cette ampleur n'est pas quelque chose à prendre à la légère, mais c'est un objectif réalisable. Bien qu'il nous reste encore du chemin à parcourir avant que ce projet ne soit terminé, TS est maintenant intégré en douceur dans notre flux de travail, et que nous ajoutions de nouvelles fonctionnalités ou convertissions d'anciennes, il a été un excellent ajout à la Console Upsun.