Dix habitudes de TypeScript qui maintiennent les grands bases de code lues et sécurisées
Apprenez dix habitudes pratiques de TypeScript, allant des génériques significatifs et du resserrement des types aux vérifications exhaustives, en passant par les propriétés readonly et une configuration tsconfig stricte, qui permettent de maintenir des bases de code en constante évolution.
La plupart des problèmes liés à TypeScript dans une base de code en expansion n’ont rien à voir avec l’ignorance du concept de type générique ou conditionnel. Ils proviennent de décisions quotidiennes : des abstractions trop complexes, des types qui acceptent trop d’informations, l’utilisation excessive du mot-clé as, des contrats définis deux fois, des signatures génériques illisibles, des fonctions dont les arguments n’ont aucun sens au moment de l’appel, des types dispersés dans des fichiers aléatoires, ainsi qu’un compilateur configuré de manière trop laxiste pour détecter ce sur quoi l’équipe compte. Dans un petit projet, ces habitudes passent presque inaperçues ; mais avec des dizaines de développeurs et au fil des années, elles se cumulent pour créer un fardeau important. Ce guide présente dix pratiques concrètes permettant de maintenir du code TypeScript facile à lire et à modifier, ainsi qu’une liste de contrôle que vous pouvez utiliser lors des revues de code.
Si vous souhaitez commencer par l’aspect de modélisation, y compris la manière de rendre les états invalides non représentables, commencez par le modélisage des domaines en TypeScript au-delà des annotations de base. Ici, l’accent est mis sur les habitudes de maintenance qui accompagnent de bons modèles.
1. Considérer les génériques comme un moyen d’exprimer des relations
Les génériques sont généralement présentés comme un mécanisme de réutilisation, et c’est bien le cas. Cependant, leur rôle plus important est de relier des types : indiquer au compilateur que ce qui sort d’une fonction est lié à ce qui y est entré. Voici le plus petit exemple utile, une fonction qui retourne le premier élément d’un tableau.
function getFirst<T>(items: T[]): T | undefined {
return items[0]
}
Le paramètre de type T est déduit de l’argument. Transmettez un tableau d’utilisateurs :
const users: User[] = [...]
et le compilateur en déduit le résultat en conséquence :
const user = getFirst(users)
// User | undefined
La même fonction fonctionne pour un type d’élément différent sans aucune annotation supplémentaire :
const products: Product[] = [...]
en fournissant un résultat de produit correctement typé :
const product = getFirst(products)
// Product | undefined
Examinez maintenant ce qui se passe si vous supprimez le paramètre générique et utilisez unknown à la place. La fonction continue de s’exécuter, mais le lien entre l’entrée et la sortie disparaît, et chaque appelant doit convertir ou restreindre le résultat.
function getFirst(items: unknown[]): unknown {
return items[0]
}
Un bon test avant d’introduire un paramètre de type consiste à nommer la relation qu’il préserve. Si vous ne pouvez pas indiquer quel type d’entrée détermine quel type de sortie, le paramètre générique ne remplit probablement pas son rôle.
Un détail à noter : avec noUncheckedIndexedAccess activé (abordé dans la section 10), items[0] est typé en T | undefined par le compilateur lui-même, ce qui correspond au type de retour explicite ici.
2. Évitez de transformer tout en générique
Comme les génériques sont puissants, ils sont facilement surutilisés. Il est tentant d’écrire une signature avec plusieurs paramètres de type restreints qui dépendent les uns des autres, comme dans l’exemple ci-dessous. Cela semble sophistiqué lorsqu’on le écrit.
function processData<
T extends Record<string, unknown>,
K extends keyof T,
R extends ...
>(...) {
// ...
}
Le développeur qui ouvre ce fichier six mois plus tard a tendance à avoir une opinion différente. Chaque paramètre de type supplémentaire est quelque chose que le lecteur doit garder en tête. Si la fonction ne traite réellement que des utilisateurs, une signature simple communique bien mieux :
function processUser(user: User) {
// ...
}
L’expertise en TypeScript ne se mesure pas à la quantité de systèmes de types que l’on peut intégrer dans une seule déclaration. Recourez à un générique lorsque celui-ci reflète une véritable relation entre types, et non simplement parce que le langage le permet.
3. Restreignez les valeurs plutôt que de les caster
Une assertion de type est le moyen le plus rapide pour faire taire une plainte :
const value = something as string
Le problème, c’est que as ne vérifie rien. Il demande au compilateur d’ignorer son incertitude et de vous croire, mais si vous avez tort, l’erreur apparaît en temps de exécution. Une approche plus sûre consiste à prouver le type par une vérification en temps de exécution que le compilateur comprend :
if (typeof something === 'string') {
console.log(something.toUpperCase())
}
Dans le bloc if, something est de type string, car typeof est une construction de restriction. Pour les types d’objets, écrivez un garde de type défini par l’utilisateur. Le type de retour value is User indique au compilateur qu’un résultat true signifie que l’argument peut être traité comme un User.
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
)
}
Les appelsurs ont alors accès gratuitement à cette restriction de type :
if (isUser(value)) {
console.log(value.name)
}
Le contraste est simple : une assertion demande au compilateur de vous faire confiance, tandis qu’un contrôle de type fournit les preuves. Gardez à l’esprit qu’un garde de type n’est honnête que dans la mesure où son corps l’est. L’exemple vérifie que id et name existent, mais pas quel type de données ils contiennent ; par conséquent, pour des données provenant du réseau ou du stockage, vous pourriez avoir besoin de vérifications plus strictes ou d’un validateur de schéma. Le compilateur fait entièrement confiance au verdict du garde.
4. Utiliser le flag never pour les branches incomplètes
Supposons qu’un état soit modélisé comme une union de littéraux de chaîne :
type Status =
| 'pending'
| 'approved'
| 'rejected'
Un switch qui associe chaque état à une étiquette semble complet :
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
}
}
Il l’est pour l’instant. Les problèmes commencent lorsque l’union s’agrandit, par exemple lorsqu’on ajoute un état de type annulé :
type Status =
| 'pending'
| 'approved'
| 'rejected'
| 'cancelled'
Status peut être utilisé en dizaines d’endroits, et vous souhaitez que le compilateur indique chacun d’eux où il ne couvre plus tous les cas. La technique standard consiste à utiliser un outil d’exhaustivité qui accepte la valeur never. Dans le branchement default, TypeScript a déjà éliminé tous les membres gérés, de sorte que le type restant doit être never. Si un nouveau membre échappe à ce contrôle, il ne peut pas être assigné à never et la compilation échoue.
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${value}`)
}
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
default:
return assertNever(status)
}
}
Après avoir ajouté 'cancelled', l’appel à assertNever(status) devient une erreur de compilation tant que le nouveau cas n’a pas été géré. La définition par union devient alors la seule source de vérité, et le compilateur génère la liste des endroits à mettre à jour. En plus, l’instruction throw vous protège en temps de exécution si une valeur inattendue arrive depuis l’extérieur du système de types.
5. Utiliser readonly pour indiquer comment les données doivent être utilisées
Les types décrivent quels valeurs sont autorisées, mais ils peuvent également indiquer comment ces valeurs doivent être traitées. Marquer une propriété readonly signale qu’elle est fixée une fois l’objet créé :
type User = {
readonly id: string
name: string
}
Une affectation telle que celle-ci est alors rejetée par le compilateur :
user.id = '123'
Les tableaux peuvent être protégés de la même manière. Un paramètre de type readonly User[] permet à la fonction d’itérer et de lire, mais pas d’ajouter des éléments, de les supprimer ou de les trier en place :
function processUsers(users: readonly User[]) {
// ...
}
Cette signature indique à tout appelant que la fonction ne modifiera pas leur collection. readonly est particulièrement utile pour les objets de configuration, les données partagées, les constantes, les paramètres de fonction et l’état immuable. L’avantage principal réside moins dans le blocage d’une mutation spécifique que dans la documentation de l’intention pour tous ceux qui consultent le type. Notez que readonly est superficiel et ne s’applique qu’en temps de compilation : les objets imbriqués restent modifiables à moins d’être eux aussi marqués ainsi, et rien n’est verrouillé en temps de exécution.
6. Ne cachez pas des structures réelles derrière Record<string, unknown>
Des signatures de ce type sont courantes :
function process(data: Record<string, unknown>) {
// ...
}
Parfois, c’est bien le type approprié. Si une fonction accepte réellement des données clé-valeur arbitraires, comme un générateur d’entrées standard ou un outil de sérialisation, utiliser un type général est honnête. Le problème survient lorsqu’on l’emploie alors qu’on connaît déjà la nature de l’objet. Prenons la même signature :
function process(data: Record<string, unknown>) {
// ...
}
et modélisons les données que nous attendons réellement :
type User = {
id: string
name: string
}
function process(user: User) {
// ...
}
Le changement semble superficiel, mais ses avantages sont importants : complétion automatique, documentation intégrée, refactoring sécurisé, garanties en temps de compilation et une intention clairement exprimée. Les types généraux conviennent aux contextes véritablement dynamiques, comme l’analyse de JSON inconnu, et devraient être remplacés par des types concrets dès que possible après ce contexte, plutôt que d’être utilisés par défaut partout.
7. Concevoir des API de fonctions qui s’expliquent d’elles-mêmes
Les arguments positionnels deviennent rapidement opaques, en particulier les booléens. En lisant une telle appelation, on ne peut pas déterminer ce que contrôlent true et false sans ouvrir la définition :
createUser(
'Akshat',
'akshat@example.com',
true,
false,
)
Un objet options place le sens directement au point d’appel :
createUser({
name: 'Akshat',
email: 'akshat@example.com',
sendWelcomeEmail: true,
isAdmin: false,
})
La fonction déclare ensuite un type nommé pour ses options :
type CreateUserOptions = {
name: string
email: string
sendWelcomeEmail: boolean
isAdmin: boolean
}
function createUser(options: CreateUserOptions) {
// ...
}
Les avantages augmentent avec le nombre de paramètres. Deux arguments sont généralement acceptables en position ; sept causent presque toujours des erreurs, surtout lorsque plusieurs partagent le même type et peuvent être échangés sans aucun problème. Un objet options permet également d’ajouter facilement des champs optionnels ultérieurement sans perturber les appels existants.
8. Conserver les types à côté du domaine qu’ils décrivent
De nombreux projets commencent avec un seul fichier types.ts partagé. Cela est pratique au début, mais chaque développeur y ajoute des éléments, et un an plus tard ce fichier contient des centaines de définitions non liées entre elles. Trouver le bon type devient alors une recherche à grande échelle, et le fichier se transforme en source fréquente de conflits lors des fusionnements.
Une meilleure approche par défaut consiste à placer les types avec le code du domaine qui les utilise :
users/
user.types.ts
user.service.ts
user.repository.ts
payments/
payment.types.ts
payment.service.ts
payment.repository.ts
facilities/
facility.types.ts
facility.service.ts
facility.repository.ts
La structure exacte des dossiers importe moins que la règle qui la sous-tend : un type doit être associé au domaine qu’il décrit. Si vous savez où se trouve la logique métier relative aux paiements, vous devriez pouvoir deviner où se trouvent également les types de paiement. Les types véritablement transversaux, tels que les en-têtes d’API partagés, peuvent tout de même être placés dans un petit module commun.
9. Gardez le système de types plus simple que la logique métier
TypeScript propose des types mappés, des types conditionnels, des types de littéraux de template, des types récursifs, infer ainsi que des conditions distributives. Avec ces outils, on peut créer presque n’importe quoi au niveau des types, c’est précisément pourquoi la retenue est importante. Prenons par exemple un outil d’aide qui filtre un objet pour ne conserver que les clés se terminant par Id:
type Magic<T> =
T extends infer U
? U extends Record<string, unknown>
? {
[K in keyof U as K extends `${string}Id`
? K
: never]: U[K]
}
: never
: never
La programmation au niveau des types a des utilisations légitimes, en particulier dans les bibliothèques. Mais il arrive qu’un type ajoute plus de complexité qu’il n’en enlève. Si un collègue doit décoder un type sophistiqué avant de pouvoir comprendre la règle métier qu’il prend en charge, demandez-vous s’une version plus simple suffirait. Parfois la réponse est non et la complexité est justifiée ; souvent, ce n’est pas le cas. L’ingéniosité n’est pas synonyme de qualité. Les types simples et lisibles l’emportent généralement sur ceux qui semblent impressionnants, et lorsque un type avancé est vraiment nécessaire, un bref commentaire ainsi que quelques tests de type facilitent grandement son entretien.
10. Configurer tsconfig de manière intentionnelle
L’un des moyens les plus simples d’affaiblir TypeScript est une configuration qui ignore justement les problèmes que vous espérez qu’il détecte. Au minimum, connaissez la fonction de ces options :
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
strict active une série de vérifications, dont strictNullChecks et noImplicitAny. Les deux autres sont des options distinctes que strict ne met pas en vigueur : noUncheckedIndexedAccess ajoute la valeur undefined aux lectures par index dans les tableaux et les enregistrements, tandis que exactOptionalPropertyTypes distingue une propriété absente d’une propriété explicitement définie sur undefined. Ces deux options peuvent faire apparaître de nombreux erreurs dans un projet existant.
La combinaison idéale dépend du codebase. Un projet ancien ne peut pas forcément activer toutes ces options en même temps, et il est tout à fait raisonnable de les activer progressivement. Ce qui importe, c’est que l’équipe sache ce que le compilateur vérifie et ne vérifie pas, en commençant par celle-ci :
"strict": true
Le mode strict n’a pas pour but de rendre TypeScript fastidieux. Il oblige le compilateur à être honnête quant aux incertitudes, ce qui est précisément l’objectif de l’utilisation de TypeScript : détecter les problèmes avant que les utilisateurs ne le fassent.
Pourquoi ces habitudes sont importantes ensemble
Aucune de ces pratiques n’a de valeur en tant que ruse. Leur intérêt réside dans le fait qu’elles rendent la base de code plus facile à comprendre. Imaginez un nouveau collègue qui tombe sur ce type :
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Sans lire aucune implémentation, il comprend une règle métier : un paiement réussi possède un identifiant de transaction, tandis qu’un paiement échoué comporte une erreur. Le type indique comment cette partie du système fonctionne, et non simplement que telle propriété est une chaîne de caractères. C’est là l’objectif à viser.
Liste de contrôle pour les revues de code
Au avant de valider du TypeScript, examinez ces questions :
- Ce
anypourrait-il êtreunknownou un type défini ? - Cette annotation répète-t-elle quelque chose que le compilateur a déjà déduit ?
- Ces types ne représentent-ils que des états de domaine valides ?
- Cette propriété est-elle optionnelle parce qu’elle l’est réellement, ou par commodité ?
- Une union décrirait-elle cet état plus précisément ?
- Ce
asest-il utilisé parce que la valeur est prouvée sûre, ou simplement pour faire disparaître une erreur ? - Ce générique exprime-t-il une relation réelle entre types ?
- L’abstraction nouvelle est-elle plus facile à comprendre que le code qu’elle remplace ?
- Un lecteur peut-il comprendre la signification de chaque argument au point d’appel ?
readonlyclarifierait-il la notion de propriété ou d’immutabilité ?- Le compilateur remarquera-t-il lorsqu’un état de domaine change ?
- Un collègue peut-il comprendre ce type sans le décoder ?
La dernière question a tendance à être la plus importante.
En résumé : les types en tant qu’outil de conception
Avec de l’expérience, la syntaxe devient la partie la moins intéressante de TypeScript. Ce qui compte, c’est ce que l’on choisit d’exprimer. On peut décrire un objet qui contient par hasard des chaînes de caractères, ou bien on peut décrire une opération qui se trouve toujours dans l’un des quatre états, chacun garantissant un ensemble spécifique de propriétés. La deuxième approche est bien plus utile.
Ainsi, une bonne implémentation en TypeScript n’est pas évaluée en fonction du nombre de fonctionnalités avancées que le développeur peut citer, mais plutôt en fonction de la capacité du système de types à aider une équipe à comprendre, modifier et maintenir le logiciel. Lorsque le compilateur applique des règles sur lesquelles l’application repose déjà, les types cessent d’être un filet de sécurité pour faire partie intégrante de l’architecture.
- Utilisez les génériques pour représenter des relations, et des signatures simples lorsqu’il n’y a pas de relation à capturer.
readonly, à des formes précises et à des objets d’options.tsconfig, et le rendre intentionnellement plus strict.