Contenu principal

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 :

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 enum peut ĂȘtre suffisant. Mais ce ne sera pas toujours pratique Ă  utiliser et vous obligera Ă  transformer votre code, lĂ  oĂč as const est 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 unknown can’t always fix an any

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 :

  1. il va voir qu’on appelle la fonction getAttributes avec un paramùtre props qui a pour type { className: string, children: string }
  2. 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)
  3. 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Ă© children avec Omit, j’ai prĂ©cisĂ© dĂšs le dĂ©part que Props Ă©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 Attributes et Props.

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 any ou as (sauf as 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 :)