Modélisation des domaines en TypeScript : au-delà des annotations de type de base
Apprenez des habitudes pratiques en TypeScript — allant de « unknown » vs « any » aux unions discriminées et à la fonction « satisfies » — qui vous aident à modéliser des états valides plutôt que de se contenter d’étiqueter des données.
Apprendre TypeScript est étonnamment simple.
D’abord, on apprend les interfaces.
Puis viennent les alias de type.
Ensuite, il y a les unions, les génériques, les types utilitaires et, de temps en temps, les types mappés.
Bientôt, on peut jeter un coup d’œil à un objet JavaScript simple et lui attribuer un type sans hésiter.
Mais à un certain stade, travailler avec TypeScript cesse d’être une question d’attribution de types aux éléments.
Cela devient plutôt une question de définition intentionnelle des types.
C’est une compétence fondamentalement différente à acquérir.
Regardez cet exemple :
type Payment = {
status: 'SUCCESS' | 'FAILED'
transactionId?: string
error?: string
}
À première vue, tout semble correct.
Mais réfléchissez aux états que ce type autorise techniquement.
Il permet tous ceux-ci :
{
status: 'SUCCESS'
}
{
status: 'SUCCESS',
error: 'Something went wrong'
}
{
status: 'FAILED',
transactionId: '123'
}
{
status: 'FAILED',
error: 'Something went wrong'
}
Ce type ne prend pas en compte quelles combinaisons sont réellement cohérentes entre elles.
Ce n’est pas une limitation de TypeScript en soi.
C’est un signe que le domaine a été mal modélisé.
Une version améliorée ressemble à ceci :
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Désormais, le système de types encode directement la règle métier réelle.
Un paiement réussi doit comporter un identifiant de transaction.
Un paiement échoué doit contenir un message d’erreur.
Les combinaisons qui n’ont pas de sens deviennent difficiles, voire impossibles, à créer.
C’est là que TypeScript commence réellement à être utile.
L’objectif n’est pas d’éparpiller des annotations de type partout où c’est possible.
C’est plutôt de faire en sorte que vos types expriment les règles que votre application suit réellement.
Voici plusieurs habitudes qui vous aident dans cette direction.
1. Cessez d’utiliser any lorsque vous voulez dire « Je ne sais pas »
L’un des moyens les plus rapides de faire taire une alerte TypeScript est le suivant :
const response: any = await fetchData()
Parfois, c’est bien ce qui se passe.
Vous rencontrez une erreur.
Vous ne pouvez pas déterminer immédiatement le type correct.
Ainsi, on utilise any.
Le compilateur se tait.
Mais toute l’aide que TypeScript vous offrait disparaît aussi.
Dès que any s’infiltrera dans votre code :
const response: any = await fetchData()
response.user.profile.name // not checked
response.foo.bar.baz // not checked
TypeScript ne dispose d’aucun moyen de détecter l’un ou l’autre de ces erreurs.
Utilisez unknown lorsque la valeur est réellement inconnue
const response: unknown = await fetchData()
Cela vous oblige à déterminer réellement ce que représente la valeur avant de l’utiliser.
if (typeof response === 'string') {
console.log(response.toUpperCase())
}
Pour tout ce qui est plus complexe qu’une valeur primitive, validez sa structure à l’endroit approprié.
La distinction est importante ici :
unknowndit "Je ne le sais pas encore."anydit "Je ne veux pas du tout que TypeScript vérifie cela."
Ces deux intentions sont très différentes.
Lorsque vous traitez des données provenant de l’extérieur de votre système, unknown est presque toujours un point de départ plus fidèle à la réalité.
2. Ne tapez pas ce que TypeScript connaît déjà
Rédiger du code fortement typé ne signifie pas de devoir annoter manuellement chaque variable.
Cette version :
const name: string = 'Akshat'
const age: number = 30
const active: boolean = true
n’est pas intrinsèquement meilleure que celle-ci :
const name = 'Akshat'
const age = 30
const active = true
TypeScript peut déjà déduire ces types par lui-même.
Annoter tout cela ne fait qu’ajouter du bruit visuel sans apporter d’informations réelles.
Les annotations explicites ont leur place lorsqu’elles transmettent un sens important.
Par exemple :
function calculateTotal(
items: Product[],
discount: number
): number {
// ...
}
Ici, la signature de la fonction documente en fait une partie d’un contrat.
Ce sont des informations véritablement utiles.
Une vérification utile à effectuer est :
Cette annotation indique-t-elle à TypeScript quelque chose qu’il ne pouvait pas déjà déduire par lui-même ?
Si la réponse est non, vous pouvez probablement l’omettre.
3. Utiliser as const lorsque les valeurs sont également des types
Considérez un objet comme celui-ci :
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
}
Parfois, vous souhaitez que les valeurs individuelles restent de types littéraux plutôt que de se transformer en string.
C’est exactement ce que as const vous permet de faire :
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
À partir de là :
type Status = typeof STATUS[keyof typeof STATUS]
on obtient :
'ACTIVE' | 'INACTIVE'
Cette approche est particulièrement utile lorsque vous avez besoin à la fois des valeurs en temps d’exécution et du type correspondant en temps de compilation à partir d’une seule définition.
Par exemple :
export const ALTERNATE_CODE_TYPES = {
CHARGE_CODE: 'CHARGE_CODE',
NFTP_MDG_CODE: 'NFTP_MDG_CODE',
FACT_MDG_CODE: 'FACT_MDG_CODE',
CW1_CHARGE_CODE: 'CW1_CHARGE_CODE',
} as const
export type AlternateCodeType =
typeof ALTERNATE_CODE_TYPES[keyof typeof ALTERNATE_CODE_TYPES]
Ici, l’objet lui-même et le type dérivé partagent une même origine.
Cela signifie que vous évitez de devoir gérer une déclaration séparée comme :
type AlternateCodeType =
| 'CHARGE_CODE'
| 'NFTP_MDG_CODE'
| 'FACT_MDG_CODE'
| 'CW1_CHARGE_CODE'
en plus de celle-ci.
Gérer une seule source de vérité est bien plus simple que de synchroniser manuellement deux définitions.
4. Utilisez les types union lorsque le domaine possède un ensemble fixe d’états
Lorsqu’une valeur ne peut prendre que quelques valeurs possibles, vos types doivent le indiquer directement.
Au lieu d’écrire :
function setStatus(status: string) {
// ...
}
préférez :
type Status = 'pending' | 'approved' | 'rejected'
function setStatus(status: Status) {
// ...
}
Avec cela en place, l’appel suivant fonctionne correctement :
setStatus('approved')
mais celui-ci est rejeté :
setStatus('something-else')
Plus vous restreignez un type, plus le compilateur peut travailler à votre place.
Cet avantage dépasse de loin la fonction d’autocomplétion des éditeurs. Une union précise aide également pour :
- le refactoring
- la documentation
- la détection d’erreurs
- la conception d’une API
- la découvrabilité
Si votre logique métier ne permet réellement que trois valeurs possibles, ne représentez pas ce champ en tant que chaîne floue.
5. Ne recourez pas automatiquement aux enums
Les enums sont parfois l’outil adapté, mais ils ne devraient pas être votre choix par défaut pour chaque ensemble de constantes.
Si tout ce dont vous avez besoin, c’est d’une union en temps de compilation, quelque chose comme :
type Status = 'ACTIVE' | 'INACTIVE'
est souvent suffisant.
Si vous avez également besoin que ces valeurs existent en temps de exécution, utilisez :
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
type Status = typeof STATUS[keyof typeof STATUS]
Cela vous donne à la fois un type et un objet réel sur lequel travailler.
Le point essentiel à retenir est que les types de TypeScript disparaissent une fois que votre code s’exécute. Un objet ordinaire, lui, ne disparaît pas.
Ainsi, la question à se poser est :
Ce champ doit-il exister en temps de exécution, ou n’est-il présent que pour restreindre les choses en temps de compilation ?
Choisissez l’approche qui correspond à la réponse.
6. Rendre les états invalides impossibles à représenter
Ce pourrait être l’idée la plus précieuse de toute cette discussion.
Imaginez un composant de formulaire qui peut être dans l’un de ces états :
- loading
- ready
- submitting
- successful
- failed
Une manière typique, mais erronée, de modéliser cela est :
type FormState = {
loading: boolean
submitting: boolean
error?: string
data?: FormData
}
Avec cette structure, rien ne vous empêche de produire accidentellement quelque chose comme :
{
loading: true,
submitting: true,
data: {...},
error: 'Something went wrong'
}
Quelle est la signification réelle de cette combinaison ? Le système de types n’en a aucune idée, tout comme le développeur qui le lira par la suite.
Une structure plus appropriée relie les champs en fonction de l’état :
type FormState =
| { status: 'loading' }
| { status: 'ready'; data: FormData }
| { status: 'submitting'; data: FormData }
| { status: 'success'; data: FormData }
| { status: 'error'; error: string }
Désormais, chaque branche contient précisément les données qui lui sont pertinentes.
function render(state: FormState) {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'ready':
return state.data
case 'submitting':
return 'Submitting...'
case 'success':
return state.data
case 'error':
return state.error
}
}
C’est là l’avantage des unions discriminées.
Au lieu de modéliser une application comme un ensemble désorganisé de booléens indépendants et de champs optionnels, on la modélise comme un ensemble fixe d’états légitimes. C’est une base bien plus fiable.
7. Faites attention aux propriétés optionnelles
Les champs optionnels ont leur utilité, mais ils constituent également un moyen facile d’introduire de l’incertitude sans s’en rendre compte.
Prenons cet exemple :
type User = {
id?: string
name?: string
email?: string
}
Avec cette définition, tout morceau de code qui consomme un User doit désormais gérer le cas où aucun de ces champs n’est présent.
Mais peut-être que la règle réelle dans ce domaine est :
Un User dispose toujours d’un ID, d’un nom et d’une adresse e-mail.
Si c’est le cas, modélisez-le en conséquence :
type User = {
id: string
name: string
email: string
}
Les propriétés optionnelles doivent correspondre à des champs qui sont vraiment parfois absents. Elles ne doivent pas servir de substitut à une reconnaissance vague du fait que celui qui a défini le type n’était pas sûr de ce que l’API allait réellement renvoyer.
Si l’incertitude provient d’un système externe, gérez-la directement à ce niveau. Ne laissez pas l’incertitude liée à une intégration se propager dans toute la base de code.
8. Comprendre null vs undefined
Dans la pratique, cette différence s’avère plus importante que ce à quoi les gens s’attendent.
Prenons ce type :
type User = {
middleName: string | null
}
Cette formulation suggère :
Le champ est présent, mais on y a délibérément mis aucune valeur.
Comparons-le maintenant à :
type User = {
middleName?: string
}
qui implique généralement :
Le champ pourrait ne pas exister du tout.
Cette distinction devient particulièrement importante dans le cadre des API. Dans une requête PATCH, ce corps :
{
middleName: null
}
peut signifier :
Supprimer le nom de famille existant.
tandis que ce corps :
{}
peut signifier :
Laisser le nom de famille inchangé.
Si les types ne permettent pas d’exprimer cette différence, de petits bugs peuvent apparaître directement au niveau de l’API.
N’oubliez pas que les types existent pour communiquer un sens, et non seulement pour satisfaire le compilateur.
9. Utilisez satisfies au lieu d’affirmer aveuglément les types
Considérez un type de configuration comme suit :
type Config = {
timeout: number
retries: number
}
Une option consiste à écrire :
const config = {
timeout: 5000,
retries: 3,
} as Config
Mais as est une affirmation, et l’utiliser signifie essentiellement demander au compilateur d’accepter la valeur sans poser de questions.
Une approche généralement meilleure est :
const config = {
timeout: 5000,
retries: 3,
} satisfies Config
Avec cette version, TypeScript vérifie réellement que l’objet correspond à Config, tout en conservant le type plus restreint déduit de l’objet littéral lui-même.
Une façon simple de se souvenir de la différence :
as
Traitez cette valeur comme si elle était de ce type.
satisfies
Vérifiez que cette valeur répond aux exigences de ce type.
C’est ce qui rend satisfies particulièrement utile pour les objets de configuration, les mappages statiques et les tables de recherche.
10. Considérer as comme une limite, et non comme un outil par défaut
Il y a des moments où une assertion de type est vraiment nécessaire. Mais écrire quelque chose comme ceci :
const user = response as User
ne vérifie en réalité rien au moment de l’exécution.
Supposons qu’une appel API renvoie réellement :
{
username: 'akshat'
}
TypeScript n’a aucun moyen de détecter cette incohérence, car l’assertion lui a déjà indiqué d’accepter la valeur telle quelle. Rien n’est prouvé au compilateur ici — on lui demande simplement de fermer les yeux.
Cela devient risqué dans les cas où des données proviennent de l’extérieur du code, comme par exemple :
- Les réponses API
- localStorage
- Les paramètres URL
- Les variables d’environnement
- Les entrées utilisateur
- Les bibliothèques tierces
Lorsque des données entrent dans une application depuis une source que TypeScript ne peut pas voir, il est préférable de les valider plutôt que de les caster. Une vérification du schéma en temps de exécution peut effectivement confirmer ce qui suit :
"Ces données correspondent bien à la structure attendue par l’application."
C’est une garantie bien plus solide que de se contenter d’écrire :
value as User
TypeScript est un outil de compilation, et un très bon outil. Il n’a jamais été conçu pour valider ce qui se passe pendant l’exécution d’un programme.
L’objectif réel : modéliser le domaine
Dès que cette façon de penser s’installe, TypeScript cesse d’apparaître comme un simple exercice de syntaxe. Au lieu de se demander « comment écrire cet objet ? », on commence à se demander « quels états cet objet peut-il réellement prendre ? ». Au lieu de se demander « cette propriété devrait-elle être optionnelle ? », on commence à se demander « est-ce que cette propriété est vraiment optionnelle, ou cache-t-elle simplement quelque chose qui n’est pas encore connu ? ». Au lieu de se demander « peut-on utiliser as ici ? », on commence à se demander « ce valeur peut-elle vraiment être prouvée avoir le type indiqué ? »
C’est ce changement de perspective qui est essentiel. Écrire du bon TypeScript ne consiste pas à ajouter davantage d’annotations de type — c’est plutôt faire en sorte que les types que l’on écrit aient réellement un sens.
Une règle simple à retenir
Chaque fois que vous concevez un type, posez-vous trois questions :
1. Quels états sont réellement valides ?
Si un type permet de représenter des états invalides, le modèle lui-même nécessite probablement une réflexion approfondie.
2. Que sait déjà le compilateur ?
Évitez d’ajouter des annotations par simple habitude — laissez l’inférence effectuer le travail pour lequel elle est déjà capable.
3. Où ces données deviennent-elles fiables ?
Plus les données voyagent loin de leur source externe initiale, plus leurs types devraient pouvoir exprimer une certitude accrue.
Un TypeScript solide n’est pas défini par la complexité de ses types. Il est défini par des types qui rendent l’implémentation correcte évidente et celle incorrecte difficile à écrire.
Lorsque les types sont conçus avec cette mentalité, TypeScript cesse de ressembler à une couche ajoutée sur JavaScript. Il devient alors partie intégrante de la manière dont une application est réellement construite.
Lectures complémentaires
- Dangers communs de JavaScript et TypeScript qui détruisent le code en secret — Explique les pièges subtils de JavaScript et TypeScript, allant des comparaisons avec NaN aux problèmes de synchronisation asynchrone et à la coercition de types, qui provoquent des erreurs bien que le code paraisse correct.
- Remplacer
anyde TypeScript : Six patterns sécurisés pour les cas courants — Découvrez des alternatives pratiques et sécurisées àanyde TypeScript — y compris les types unknown, les génériques, les unions discriminées et les vérifications exhaustives — pour gérer des données imprévisibles.