Accueil / Articles / Modélisation des domaines en TypeScript : au-delà des annotations de type de base

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.

2330 mots

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 :

unknown dit "Je ne le sais pas encore." any dit "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

  • Maîtriser les types utilitaires intégrés de TypeScript pour un code plus propre — Découvrez comment les types utilitaires de TypeScript tels que Partial, Pick, Omit et Record éliminent les interfaces redondantes et maintiennent automatiquement les définitions de types en synchronisation.