3 RĂšgles d'or en TypeScript
lundi 04 mars 2024
En ce moment je joue pas mal avec TypeScript. Notamment je suis en train de vous prĂ©parer une exploration assez chouette (jâespĂšre đ) sur son utilisation en tandem avec Stimulus.
Mais avant de vous parler de ça, je me devais de faire une introduction sur comment jâutilise TypeScript au quotidien. Quâest-ce qui pour moi fait quâune base de code Ă©crite en TypeScript est saineâŻ? Quelles sont les rĂšgles Ă mettre en place pour que TypeScript vous soit utile plutĂŽt que de vous rajouter une charge grandissante de travailâŻ?
Est-ce que cet article est fait pour vousâŻ? Il ne sâagit pas dâun article dâintroduction, il vous faut donc avoir dĂ©jĂ un peu utilisĂ© TypeScript. Par contre, si dans votre code, vous vous sentez souvent bloquĂ© par le compilateur ou si vous avez souvent un sentiment de dĂ©faite quand vous lâutilisez, vous y trouverez sĂ»rement quelques infos pertinentes. đ€
Pour commencer, revenons Ă la raison pour laquelle on utilise cet outil. Il apporte notamment deux composantes dont on a du mal Ă se passer une fois quâon y a goĂ»té :
- le filet de sĂ©curité : si vous nâutilisez pas une variable ou une fonction de la bonne façon, il vous criera dessus
- lâexpĂ©rience de dĂ©veloppement et notamment la qualitĂ© de son autocompletion : facilitant ainsi la dĂ©couverabilitĂ© de votre code ou de celle des librairies que vous utilisez
Cependant, TypeScript peut vite devenir douloureux dÚs lors :
- quâon pousse le compilateur Ă ignorer certains types, ce qui crĂ©e des trous bĂ©ants dans notre filet de sĂ©curitĂ©
- quâon réécrit les types un peu partout, ce qui rend chaque changement et mise Ă jour pĂ©nible
Pour sâen prĂ©munir, je vais vous prĂ©senter ce que je considĂšre comme les 3 rĂšgles dâor quand je code au quotidien :
- définir les types uniquement lorsque nécessaire (essentiellement pour des signatures de fonction)
- ne (presque) jamais utiliser
anyou deas - privilégier des dérivations de types plutÎt que de les réécrire
Il existe Ă©videmment des exceptions Ă ces rĂšgles, mais en les ayant en tĂȘte, vous devriez pouvoir amĂ©liorer la qualitĂ© de votre code et surtout votre DX.
Définir des types uniquement lorsque nécessaire
// â Ă Ă©viter
const values: string[] = ['mon', 'tableau'];
// â
préférer
const values = ['mon', 'tableau'];
TypeScript est muni dâun systĂšme dâinfĂ©rence. Ce que ça veut dire, câest quâil va essayer au maximum de dĂ©finir les types Ă partir des valeurs que vous lui donnez.
Par exemple, dans le code ci-dessus, on nâa pas besoin de lui dire que câest un tableau de string parce quâil va le voir directement Ă partir de la valeur quâon a assignĂ©. Profiter de cette fonctionnalitĂ© vous allĂ©gera grandement la lecture et lâĂ©criture du code. Par exemple, quand on assigne le rĂ©sultat dâune fonction Ă une variable :
const value = getValue();
Câest le type de retour de getValue qui exprime le type de la variable value. Inutile donc de lâĂ©crire vous mĂȘme.
Il y a cependant un endroit oĂč TypeScript ne va pas rĂ©ussir Ă faire le travail Ă votre place, câest sur les dĂ©finitions de fonctions.
function sum(a, b) {
return a + b;
}
Ici, il ne va pas ĂȘtre capable de savoir que a et b doivent ĂȘtre des number. Il faut donc lui expliquer :
function sum(a: number, b: number) {
return a + b;
}
Ce sera vrai pour tous les paramĂštres de fonctions.
Par contre, le type de retour est facultatif. En effet, on nâa pas eu besoin dâĂ©crire :
function sum(a: number, b: number): number {
return a + b;
}
Toutefois, câest lâexception oĂč jâai tendance Ă ajouter un type mĂȘme si lâinference peut le faire Ă ma place. En effet, le fait dâĂ©crire ce type nous force Ă penser au fonctionnement de la fonction avant de la coder.
Ca va ĂȘtre particuliĂšrement utile pour les cas limites, les cas oĂč le type nâest pas stable. Prenons lâexemple dâune fonction hello : celle-ci doit retourner une string Hello Julien si on lâappelle avec hello('Julien').
function hello(name: string) {
return `Hello ${name}!`;
}
Mais que faire si finalement name peut-ĂȘtre nullableâŻ? Si on nâa pas anticipĂ© le type de retour, le premier rĂ©flexe serait dâĂ©crire :
function hello(name: string | null) {
// on en profite pour aussi gérer le cas d'une chaßne vide
if (!name) {
return null;
}
return `Hello ${name}!`;
}
Mais du coup on insĂšre une complexitĂ© de type parce que la valeur de retour peut-ĂȘtre soit null soit string. Ca veut dire que partout oĂč je vais utiliser la fonction hello, je vais devoir me trainer ce null. Et celui-ci va sĂ»rement continuer Ă se rĂ©pandre, nous forçant Ă faire des conditions !== null dans tous les sens.
Alors quâen forçant le type de retour function hello(name: string | null): string, TypeScript nous aurait aidĂ© Ă dĂ©couvrir le fait que retourner null change la signature de la fonction et donc la complexifie.
Et donc, pour éviter ces null, ici, on préfÚre retourner une string par défaut :
function hello(name: string | null): string {
if (!name) {
return 'Hello world!';
}
return `Hello ${name}!`;
}
as const
Une autre situation oĂč TypeScript ne va pas parfaitement anticiper votre besoin, câest pour les chaines de caractĂšres statiques.
Câest souvent le cas des constantes :
const STATUS_LOADING = 'loading';
// type 'loading' et non string
const STATUS_COMPLETE = 'complete';
// type 'complete' et non string
const STATUS_ERROR = 'error';
// type 'error' et non string
La diffĂ©rence ici est quâil est capable de comprendre que ce nâest pas nâimporte quelle string, mais uniquement celle que vous avez Ă©crit. Cela vient du fait que vous avez dĂ©clarĂ© votre variable avec le mot clĂ© const.
Mais si on lâavait Ă©crit diffĂ©rement, TypeScript les aurait interprĂ©tĂ© comme des strings :
const statuses = {
loading: 'loading',
complete: 'complete',
error: 'error'
};
// type {
// loading: string,
// complete: string,
// error: string,
// }
Maintenant, admettons que vous voulez crĂ©er une variable qui doit avoir pour valeur uniquement lâune des string dĂ©finie dans statuses, vous Ă©cririez le code suivant :
type Values<T extends Record<string, unknown>> = T[keyof T];
let status: Values<typeof statuses>;
đĄ Pas de panique si ce code vous paraĂźt chelou, on y reviendra dans la troisiĂšme rĂšgle sur la dĂ©rivation de types.
Mais du coup, câest comme si on avait Ă©crit let status: string. Donc si vous faites status = "toto", TypeScript nây verra pas dâinconvĂ©nient. Or on voulait uniquement une chaĂźne parmi les statuses disponibles.
Une solution trĂšs pratique dans cette situation est dâajouter un petit as const Ă la fin de votre valeur.
const statuses = {
loading: 'loading',
complete: 'complete',
error: 'error'
+} as const;
Grùce à ce petit changement, le type que vous manipulez est beaucoup plus précis :
{
readonly loading: 'loading',
readonly complete: 'complete',
readonly error: 'error',
}
Et donc, le type de la variable status ne sera plus une string, mais 'loading' | 'complete' | 'error', alors que vous nâavez presque pas changĂ© votre code.
Ca va ĂȘtre particuliĂšrement utile pour tout ce qui est gestion de constantes et de configurations dans votre code. NâhĂ©sitez pas Ă lâutiliser plutĂŽt que dâĂ©crire manuellement vos types.
đĄ A noter que dans certains cas, utiliser une
enumpeut ĂȘtre suffisant. Mais ce ne sera pas toujours pratique Ă utiliser et vous obligera Ă transformer votre code, lĂ oĂčas constest parfaitement compatible avec nâimporte quel code JS.
Passons maintenant Ă la deuxiĂšme rĂšgle.
Ne jamais utiliser any
any est le mode open bar de TypeScript. Si vous dĂ©clarez le type dâune variable en any, vous pouvez faire nâimporte quoi avec. Vous essayez de faire une somme avec un nombreâŻ? Pas de problĂšme. Vous essayez de rĂ©cupĂ©rer une de ses clĂ©sâŻ? Bien sĂ»r. Une clĂ© Ă lâintĂ©rieur de cette clĂ©âŻ? Yup. MĂȘme si ça valeur rĂ©elle est undefined đ€·
Pour cette raison, il est de bon ton de lâĂ©viter autant que possible. Sinon, câest comme si vous faisiez du JS normal : pas de filet de sĂ©curitĂ©, pas dâautocompletion.
Mais des fois, vous ne savez vraiment pas quel type utiliser.
Prenons lâexemple des Props en React : la valeur dâune propriĂ©tĂ© peut vraiment ĂȘtre tout et nâimporte quoi. Un nombre, un boolĂ©en, une string, un objet, une fonction, etc.
Donc le premier réflexe est de décrire les propriétés de cette façon :
type Props = Record<string, any>;
// ou bien comme ceci
type Props = { [key in string]: any };
Mais si on fait ça, alors on peut manipuler les propriĂ©tĂ©s nâimporte comment :
props.quantity + props.className; // ok
Alors que si on remplace any par unknown, TypeScript nous crie dessus en indiquant quâil ne connait pas les types des trucs quâon essaye de manipuler.
props.quantity + props.className;
// props.quantity is of type unknown
// props.className is of type unknown
Câest dĂ©jĂ une bonne premiĂšre Ă©tape : maintenant on est obligĂ© de considĂ©rer que le format de la donnĂ©e quâon a reçu nâest pas Ă©vident.
Une option quâon voit souvent est dâutiliser le mot clĂ© as :
const quantity = props.quantity as number;
// quantity: number
Mais en faisant ça, on ne fait que dire Ă TypeScript : « Aie confiance » đ”âđ«đ. Câest un open bar, mais un open bar conscient. Parfois ça peut vous permettre dâaller plus vite dans des situations oĂč vraiment il nây a aucune chance que ce soit faux. Mais jâai tendance Ă le dĂ©conseiller parce que ça reste de lâopen bar.
Une meilleure méthode est de vérifier ou de forcer avec du JS le type de nos données avant de les utiliser :
// En castant la donnée
const quantity = Number(props.quantity);
// quantity: number
if (Number.isNaN(quantity)) {
// Le type est bon, mais il faut aussi
// penser Ă gĂ©rer le cas oĂč ce n'est
// pas un vrai nombre
}
// Ou en vérifiant son type
const quantity = props.quantity;
if (typeof quantity === 'number') {
// ...
}
Ce sera parfois fastidieux et câest dans cette situation que des librairies comme Zod ou Valibot peuvent vous aider.
Mais au moins, grĂące à ça, vous ĂȘtes sĂ»r de la qualitĂ© de vos donnĂ©es.
đĄ Une bonne lecture Ă ce sujet serait lâarticle de Matt Pocock : An
unknowncanât always fix anany
Cela dit, la plupart du temps, vous nâĂȘtes pas vraiment censĂ© avoir besoin ni dâany, ni dâunknown. Notamment, le code parent a sĂ»rement dĂ©jĂ fait la vĂ©rification du type sur quantity. A ce moment lĂ , une meilleure solution serait sĂ»rement dâutiliser la dĂ©rivation de type.
â ïž Il existera toujours des exceptions. Si par exemple la donnĂ©e a Ă©tĂ© dĂ©finie 2 lignes plus haut, quâau niveau du code il y a aucun risque, mais que câest compliquĂ© de rajouter des types, alors oui, choisissez la mĂ©thode la plus simple. Ajoutez un commentaire pour la prochaine personne qui passera par lĂ , des tests automatisĂ©s et passez Ă la suite. Lâessentiel est de ne pas ĂȘtre dogmatique, mais dâavoir en tĂȘte les risques que cela comporte.
Privilégier la dérivation de types
Lorsque vous travaillez avec des types scalaires (string, number, boolean, etc.), dans lâensemble vous aurez peu besoin de vous rĂ©pĂ©ter parce que lâinfĂ©rence de TypeScript fonctionne trĂšs bien.
Par contre, dĂšs que vous commencez Ă manipuler des objects, TypeScript va avoir plus de mal Ă deviner vos types de retour.
Prenons lâexemple dâune fonction qui veut rĂ©cupĂ©rer tout un objet sauf la clĂ© children. Celle-ci peut ĂȘtre pratique pour traiter des props en React par exemple :
function getAttributes(props) {
const attributes = { ...props };
delete attributes['children'];
return attributes;
}
const props = {
className: 'button',
children: 'Envoyer'
};
// type: { className: string, children: string }
const attributes = getAttributes(props);
// { className: 'button'}
Si on décrit la fonction getAttributes de la maniÚre la plus directe possible en TS, on écrirait :
function getAttributes(props: Record<string, unknown>): Record<string, unknown>;
AprÚs tout, ça fonctionne : en entrée on prend un objet clé/valeur, et en sortie on retourne un nouvel objet clé/valeur.
Le problĂšme câest que maintenant, si je veux rĂ©utiliser lâattribut className, TypeScript nâest pas content :
attributes.className; // type `unknown`
Comment faire pour que TypeScript connaisse le type de classNameâŻ? On nâa pas le droit de faire un attributes.className as string parce que ça voudrait dire forcer la main Ă TypeScript et donc rater une catĂ©gorie dâerreur. (cf. premiĂšre rĂšgle)
La solution va plutĂŽt ĂȘtre dâamĂ©liorer la dĂ©finition de type de notre fonction. En effet, pour lâinstant, on a Ă©crit que le type de retour Ă©tait un nouveau record au niveau de la signature de la fonction. Mais en rĂ©alitĂ©, ça devrait ĂȘtre le mĂȘme Record auquel on a enlevĂ© une clĂ©.
function getAttributes(props: Record<string, unknown>): Record<string, unknown>;
// ^ ce Record est différent de ^
Pour expliquer ça Ă TypeScript on va devoir en faire un âGenericâ. Dans lâesprit, câest comme si on stockait un type dans une variable :
function getAttributes<
Props extends Record<string, unknown>
>(props: Props): Props;
đĄ Si vous avez plutĂŽt l'habitude de lire getAttributes<T extends ...> et que ce Props vous paraĂźt curieux, cliquez ici pour lire l'explication.
Quand vous regardez du code typĂ© qui utilise des gĂ©nĂ©rics, vous trouverez souvent ces variables de type nommĂ©es avec une seule lettre: T, K, V, etc. Souvent il faut considĂ©rer que câest lâinitial du nom que vous voulez lui donner : Type, Key, Value. Mais rien ne vous empĂȘche dâutiliser des vrais mots (comme ici Props au lieu de P).
Câest une histoire dâavantages et dâinconvĂ©nients :
- utiliser une seule lettre permet de vraiment distinguer le fait quâon est en train de manipuler un type gĂ©nĂ©rique
- utiliser un mot complet facilite la lecture immédiate, mais peut parfois faire doublon avec un autre type externe déjà défini
Choisissez donc la mĂ©thode qui fait le plus de sens dans votre contexte. Il nây a pas de rĂšgle universelle si ce nâest celle qui marche dans votre Ă©quipe. :)
Revenons maintenant Ă nos moutons.
Dans le code ci-dessus, on a donc stocké dans la variable de type Props un type qui doit respecter la contrainte Record<string, unknown> grùce au mot clé extends. Le type Props sera donc forcément un objet clé/valeur.
Ainsi, quand TypeScript va compiler le code :
- il va voir quâon appelle la fonction
getAttributesavec un paramĂštrepropsqui a pour type{ className: string, children: string } - de fait, il va comprendre tout seul que le type
Props = { className: string, children: string }(câest ce quâon appelle de lâinfĂ©rence) - Et donc, le type de retour de la fonction Ă©tant
Props, il rĂ©utilisera exactement le mĂȘme format dâobjet :{ className: string, children: string }
Câest dĂ©jĂ une excellente premiĂšre Ă©tape, mais pour lâinstant attributes va ĂȘtre un objet qui a toujours la clĂ© children. On va donc avoir besoin de transformer le type de retour pour retirer la clĂ© children de lâobjet initial : Omit<Props, 'children'>.
function getAttributes<
T extends Record<string, unknown>
>(props: T): Omit<T, 'children'>;
Donc si je lui avais passé en entrée { className: string, children: ReactNode }, alors le type de retour de ma fonction getAttributes serait { className: string }.
Et du coup, quand je vais manipuler le rĂ©sultat de la fonction, jâaurais bel et bien le bon type :
const attributes = getAttributes(props);
attributes.className; // type string
đĄ Il existe pas mal de types tels que Omit qui sont dĂ©jĂ dans TypeScript et qui peuvent vous ĂȘtes utiles : Utility Types. NâhĂ©sitez donc pas Ă y jeter un coup dâoeil pour les avoir en tĂȘte le jour oĂč vous en aurez besoinâŻ!
đĄ Par ailleurs, tout comme dans nâimporte quel code, il y a souvent plusieurs mĂ©thodes pour arriver Ă vos fins. Il peut donc parfois ĂȘtre utile de retourner le problĂšme pour vous en sortir. La premiĂšre idĂ©e qui vous vient en tĂȘte nâest pas toujours la meilleure.
Par exemple, on aurait pu changer nos types de cette façon :
function getAttributes< Attributes extends Record<string, unknown> Props extends Attributes & {children: ReactNode} >(props: Props): Attributes;PlutÎt que de retirer la propriété
childrenavecOmit, jâai prĂ©cisĂ© dĂšs le dĂ©part quePropsĂ©tait constituĂ© de tout un tas de clĂ© (Attributes) ET de la clĂ©children.De plus, je vous avais dit que vous pouviez ajouter une variable de type pour en faire un gĂ©nĂ©rique. Mais en rĂ©alitĂ© on peut en ajouter autant quâon veut et les nommer comme bon nous semble. Câest pour ça quâici jâai dĂ©finit
AttributesetProps.
Types génériques
Pour finir, en vous parlant de dĂ©rivation de type, je vous lâai prĂ©sentĂ© en partant directement dâune fonction. Mais sachez que cette notion de gĂ©nĂ©ricitĂ© des types est aussi disponible pour un type seul. Cela fonctionne presque pareil :
type Attributes<T extends Record<string, unknown>> = Omit<T, 'children'>;
Câest pratique parce que ça vous permet de rĂ©utiliser le mĂȘme typage Ă plusieurs endroits. Ainsi, jâaurais pu rĂ©crire ma fonction de cette façon :
function getAttributes<
T extends Record<string, unknown>
>(props: T): Attributes<T>;
Plus votre code est gĂ©nĂ©rique, plus vous aurez besoin dây avoir recours. Les possibilitĂ©s sont rĂ©ellement infinies. Vous pouvez aller trĂšs loin dans la dĂ©finition de vos types.
Cela dit, on peut aussi vite sây perdre. Câest vraiment la partie la plus velue de TypeScript, et ça met du temps Ă rentrer. Mais petit Ă petit vous deviendrez Ă lâaise avec certains concepts et pourrez en dĂ©couvrir dâautres.
Pour vous aider dans cette quĂȘte, nâhĂ©sitez pas Ă aller lire la suite de cet article : âMaĂźtriser les types avancĂ©s en TypeScriptâ.
Sachez aussi quâune excellente ressource pour continuer de vous amĂ©liorer est Total TypeScript (EN). Il va bientĂŽt publier un bouquin, mais vous avez dĂ©jĂ beaucoup Ă apprendre juste avec la partie gratuite de son contenu, profitez-enâŻ!
Conclusion
Nous voilà arrivé au bout, avec un peu de sueur, mais on a tenu bon.
Ce quâon a vu câest que TypeScript est rĂ©ellement capable de disparaĂźtre une fois que vous avez bien typĂ© vos briques fondatrices. Ca vous Ă©vitera toute une catĂ©gorie de bugs, mais aussi amĂ©liorera grandement votre expĂ©rience de dĂ©veloppement.
Pour cette raison, cela vaut donc le coup dâinvestir dans des bases saines qui passent par les 3 rĂšgles :
- définir uniquement les types nécessaires
- ne jamais utiliser
anyouas(saufas const) - dériver vos types plutÎt que de les réécrire
Et vous, vous en auriez vu dâautresâŻ?
Si vous voulez suivre mes publications, il paraĂźt que j'ai un feed RSS, Mastodon et un Twitter.
Si vous pensez à d'autres méthodes que vous voudriez que je mette en place (pigeon voyageur, avion en papier, etc.), n'hésitez pas à me les proposer :)