Module 2 · Fondations

Lire les types TypeScript

Les types ne sont pas là pour t'embêter : ce sont des étiquettes sur la forme des données. Bien lus, ils répondent à « qu'est-ce qu'il y a là-dedans ? » avant même que tu lises une seule ligne de logique. Ce module t'apprend à les lire comme on lit une carte avant de partir en randonnée — calmement, du regard d'ensemble vers le détail.

💡 Le concept

TypeScript = JavaScript + des annotations qui décrivent la forme des valeurs. name: string veut dire « name est forcément du texte ». Ces annotations disparaissent à l'exécution — c'est du JavaScript tout à fait normal qui tourne dans le téléphone — mais pendant que tu écris du code, elles te disent ce que tu as le droit de faire et t'avertissent si tu te trompes. C'est un assistant qui lit par-dessus ton épaule, jamais un poids mort au moment où l'app fonctionne.

Pourquoi des types ? Le filet de sécurité

JavaScript, c'est un langage qui te fait confiance aveuglément. Tu peux écrire workout.titel au lieu de workout.title : JavaScript ne dira rien, il te rendra simplement undefined, et le bug n'apparaîtra que plus tard, à l'exécution, chez un utilisateur, sous la forme d'un écran blanc ou d'une valeur manquante. Tu passes alors une heure à chercher une faute de frappe. TypeScript existe précisément pour transformer ce genre d'erreur silencieuse et tardive en erreur bruyante et immédiate : il souligne titel en rouge dans ton éditeur, avant même d'avoir lancé quoi que ce soit.

On peut résumer ce que les types t'apportent en quatre bénéfices très concrets, qui reviennent tous les jours quand on travaille sur une vraie app comme Halterofit :

  • Attraper les erreurs AVANT l'exécution. Une grande partie des bugs ne sont pas de la logique compliquée, mais des bêtises : un champ mal orthographié, un nombre traité comme du texte, un objet qui pouvait être null et qu'on a oublié de vérifier. TypeScript les signale pendant que tu tapes, pas trois jours plus tard.
  • Une documentation vivante. Quand tu veux savoir ce qu'un workout contient, tu n'as pas besoin de fouiller la base de données ni de demander à quelqu'un : tu lis l'interface Workout. Et contrairement à un commentaire ou à un fichier README, cette documentation ne peut pas mentir : si elle se trompait, le code ne compilerait plus. Elle est toujours à jour, par construction.
  • L'autocomplétion. Parce que l'éditeur connaît la forme exacte de tes données, il te propose les bons champs quand tu tapes un point. Tu écris workout. et la liste id, user_id, started_at… apparaît. Tu codes plus vite et tu te trompes moins, parce que tu n'as plus à te souvenir des noms exacts.
  • Le refactoring sûr. Le jour où tu renommes un champ, où tu changes un type, où tu supprimes une propriété, TypeScript te montre tous les endroits qui cassent. Renommer en confiance, sans en oublier un seul, c'est un superpouvoir quand le projet grossit.

La bonne image mentale : TypeScript est un filet de sécurité tendu sous le funambule. Il ne change rien à la marche sur le fil — c'est toujours ton JavaScript qui s'exécute — mais il te rattrape quand tu glisses. Et ce filet a une particularité : il disparaît à l'exécution. Au moment où l'app tourne, il ne reste plus aucune trace des types ; il n'y a que du JavaScript pur. On appelle ça l'effacement de types (« type erasure »).

💡 Le concept : l'effacement de types

Un outil (le compilateur) lit ton TypeScript, vérifie que tout est cohérent, puis retire toutes les annotations pour produire du JavaScript ordinaire. C'est ce JavaScript-là qui s'installe sur le téléphone. Conséquence importante : un type ne peut pas te protéger à l'exécution. Si une donnée arrive du réseau avec une forme inattendue, TypeScript ne la bloquera pas en cours de route — il avait fait son travail avant. C'est pour ça qu'on écrit, en plus, des vérifications « à la main » comme parseSetType (qu'on verra plus loin) aux frontières où des données douteuses entrent. On reparlera de cette étape de compilation dans le module sur l'outillage et le build.

L'inférence : TypeScript devine souvent tout seul

Première grande surprise pour qui débute : on n'annote pas tout. Loin de là. Dans une base de code bien écrite, la majorité des variables n'ont aucune annotation visible, et pourtant elles sont parfaitement typées. La raison : TypeScript fait de l'inférence, c'est-à-dire qu'il déduit le type à partir de la valeur que tu lui donnes.

// Aucune annotation, et pourtant TypeScript SAIT.
let reps = 8;          // inféré : number
let titre = 'Push A';  // inféré : string
let fini = false;      // inféré : boolean

reps = 'huit';         // ❌ erreur : on a promis un number, pas un string

Tu n'as rien écrit comme : number, mais TypeScript l'a déduit de la valeur initiale 8. La dernière ligne est refusée parce qu'elle contredit ce que l'inférence avait conclu.

Ce mécanisme rend le code à la fois plus léger à écrire (moins d'annotations) et tout aussi sûr. Tu le retrouveras partout dans React. L'exemple le plus célèbre, c'est useState :

// On donne 0 comme valeur initiale.
// TypeScript en déduit que x est un number, et que setX
// n'accepte que des number. On n'a RIEN annoté.
const [x, setX] = useState(0);

setX(5);       // ✔ ok
setX('cinq');  // ❌ erreur : setX attend un number

Ici x est inféré comme number uniquement grâce au 0 de départ. C'est l'inférence qui fait que React « connaît » le type d'un état sans qu'on le lui dise. On creuse useState dans le module sur le modèle de React et les états.

Quand faut-il alors annoter explicitement ? Surtout à deux endroits : aux frontières (les paramètres d'une fonction, ce qu'elle retourne — l'inférence ne peut pas deviner ce que l'appelant va envoyer) et quand la valeur initiale est trop pauvre pour deviner (par exemple useState(null) : avec juste null, TypeScript ne peut pas savoir ce que ça deviendra plus tard ; il faut l'aider — on verra comment dans la section sur les génériques). Pour le reste, laisse l'inférence travailler : un code surchargé d'annotations inutiles est plus pénible à lire, pas plus sûr.

🧭 Bon à savoir

Un réflexe utile dans l'éditeur : survole une variable avec la souris. Une infobulle t'affiche le type que TypeScript a inféré. C'est le moyen le plus rapide de vérifier « qu'est-ce que TypeScript pense que c'est ? » sans rien écrire. Quand un type t'intrigue, survole-le.

Une interface = la forme d'un objet

On entre dans le cœur du sujet. Une interface décrit les champs qu'un objet doit avoir, et le type de chacun. C'est le plan de construction d'un objet : pas l'objet lui-même, mais la promesse de sa forme. Voici une vraie table de la base de données de Halterofit :

apps/mobile/src/services/database/remote/types.ts
export interface User {
  id: string;                          // texte obligatoire
  email: string;
  preferred_unit: 'kg' | 'lbs';        // SOIT 'kg' SOIT 'lbs', rien d'autre
  default_rest_timer_seconds?: number; // le ? = champ FACULTATIF
  created_at: number;                  // timestamp en millisecondes
  updated_at: number;
}

Trois choses à repérer instantanément : : string / : number = le type de chaque champ ; 'kg' | 'lbs' = un choix fermé (une union, voir plus bas) ; ? = le champ peut être absent (undefined).

Lis une interface comme une fiche d'identité. Chaque ligne te dit un fait sur l'objet : son nom, son type, et s'il est obligatoire ou non. Prends l'habitude de parcourir d'abord les noms de champs (pour comprendre de quoi l'objet parle), puis les types (pour comprendre comment manipuler chaque champ). En quelques secondes, tu sais qu'un User a forcément un email (du texte), une unité préférée qui ne peut être que 'kg' ou 'lbs', et qu'il peut avoir un timer de repos par défaut — ou pas.

Regardons une interface plus riche, qui montre un autre type très courant : le tableau.

export interface Exercise {
  id: string;
  name: string;
  body_parts: string[];        // une LISTE de textes : ['chest', 'shoulders']
  target_muscles: string[];    // string[] se lit "tableau de string"
  instructions: string[];
  gif_url?: string;            // facultatif : tous les exercices n'ont pas de gif
  is_starred?: boolean;        // favori local, facultatif
  created_at: number;
  updated_at: number;
}

string[] se lit « un tableau de string », donc une liste de textes. Le champ is_starred?: boolean illustre encore le ? : un exercice peut avoir, ou ne pas avoir, été marqué comme favori.

⚠️ Piège fréquent

Le ? change tout. default_rest_timer_seconds?: number veut dire que la valeur peut être un nombre ou ne pas exister du tout (undefined). Avant de l'utiliser — par exemple de l'additionner ou de l'afficher — le code doit gérer le cas « absent », sinon TypeScript se plaint, à juste titre. Oublier qu'un champ marqué ? peut être undefined est l'une des erreurs les plus communes quand on débute : on lit le type comme « un nombre » alors qu'il faut le lire « un nombre ou rien ».

Interface ou type ? Deux façons de nommer une forme

Tu vas croiser deux mots-clés pour donner un nom à une forme : interface et type. La bonne nouvelle : en pratique, pour décrire un objet, ils sont souvent interchangeables. Les deux extraits suivants décrivent exactement la même chose :

// Avec interface
interface Point {
  x: number;
  y: number;
}

// Avec type — strictement équivalent ici
type Point = {
  x: number;
  y: number;
};

Pour un simple objet, choisir l'un ou l'autre ne change rien d'observable. Ne te bloque pas là-dessus en lisant du code : traite-les comme deux façons d'écrire « voici une forme nommée ».

La différence se voit surtout dans ce qu'ils savent faire en plus. Une interface est faite pour décrire la forme d'un objet et peut s'étendre avec extends (on le verra avec WorkoutWithDetails). Un type (alias de type) est plus polyvalent : il peut nommer n'importe quel type, pas seulement un objet — une union, un tableau, le résultat d'un calcul de types. Tu ne peux pas écrire une union avec interface, mais tu peux avec type :

// Ça, SEUL `type` peut le faire : nommer une union.
type SetType = 'normal' | 'warmup' | 'failure' | 'dropset';

// Et nommer un type dérivé d'un autre (utility type) :
type CreateWorkout = Omit<Workout, 'id'>;

Règle de pouce confortable : interface pour la forme des objets et des entités (User, Workout…), type dès qu'il s'agit d'une union ou d'un type fabriqué à partir d'un autre. C'est exactement ce que fait Halterofit dans types.ts.

🏋️ Dans Halterofit

Ouvre mentalement types.ts : les entités (User, Exercise, Workout, ExerciseSet, WorkoutPlan…) sont toutes des interface, parce que ce sont des formes d'objets, certaines reliées par extends. Les dérivés (SetType, CreateWorkout, UpdateWorkout, SyncableTableName…) sont des type, parce que ce sont des unions ou des transformations. Quand tu repères cette logique, le fichier devient lisible d'un coup d'œil : « interface = une chose réelle ; type = quelque chose de calculé à partir d'une chose réelle ».

Les unions et le narrowing : « soit ceci, soit cela »

La barre | se lit « OU ». 'kg' | 'lbs' autorise exactement ces deux textes, et aucun autre : ce n'est pas « du texte en général », c'est un choix fermé entre deux valeurs précises. C'est puissant : le type lui-même interdit les fautes de frappe comme 'kgs' ou 'pounds'. Halterofit pousse l'idée plus loin pour les types de séries :

// 1. On déclare la liste, figée avec `as const`.
//    `as const` dit "ces valeurs ne changeront jamais", ce qui permet
//    à TypeScript de les traiter comme des littéraux exacts.
export const SET_TYPES = ['normal', 'warmup', 'failure', 'dropset'] as const;

// 2. On FABRIQUE le type à partir de la liste, sans le retaper.
//    (typeof SET_TYPES)[number] = "n'importe quel élément du tableau".
//    Résultat : SetType = 'normal' | 'warmup' | 'failure' | 'dropset'
export type SetType = (typeof SET_TYPES)[number];

Astuce de pro : une seule source de vérité. Si on ajoute 'amrap' à la liste, le type SetType se met à jour tout seul. Tu rencontreras souvent ce motif as const + [number] ; pour le lire, retiens juste « le type de tous les éléments possibles du tableau ».

Maintenant, le point vraiment important, celui qui débloque la lecture de beaucoup de code : le narrowing (le « rétrécissement »). Quand une valeur a un type union, tu ne peux pas encore faire grand-chose avec — TypeScript ne sait pas lequel des membres tu tiens. Mais dès que tu écris un if qui le vérifie, TypeScript déduit le résultat de ce test et, à l'intérieur du bloc, te traite la valeur comme le membre prouvé. Le if ne fait pas que choisir un chemin : il prouve un type.

function decrire(unite: 'kg' | 'lbs'): string {
  if (unite === 'kg') {
    // ICI, TypeScript SAIT que `unite` vaut 'kg'.
    // Le if a "rétréci" l'union au seul membre 'kg'.
    return 'kilogrammes';
  }
  // ICI, le seul membre restant possible est 'lbs'.
  // TypeScript le déduit tout seul : l'union a été épuisée.
  return 'livres';
}

Avant le if, unite est « 'kg' ou 'lbs' ». Après la vérification, dans chaque branche, c'est l'un OU l'autre, déterminé. C'est ça, le narrowing : chaque test réduit l'ensemble des possibilités.

Ce même mécanisme s'applique aux champs facultatifs et à null. Un champ weight?: number a le type « number ou undefined ». Tu ne peux pas l'additionner directement. Mais après if (weight != null), TypeScript sait que dans ce bloc weight est un vrai number : le test a éliminé le undefined. C'est exactement pour ça qu'on est obligé de « vérifier avant d'utiliser » un champ optionnel — et le narrowing rend cette obligation indolore, parce qu'une fois le test écrit, le type devient propre tout seul.

Les type guards : prouver le type à l'exécution

Souviens-toi de l'effacement de types : à l'exécution, les annotations ont disparu. Aux frontières où des données douteuses entrent dans l'app — une réponse réseau, une vieille ligne de base de données écrite avant une migration — TypeScript n'a aucun moyen de garantir la forme. C'est à toi de vérifier, à la main, et de signaler le résultat à TypeScript. Une fonction qui fait ça s'appelle un type guard : elle vérifie une valeur et garantit son type en sortie. Voici celui qui protège Halterofit contre des types de série invalides :

// Prend un texte douteux (string | null | undefined) et garantit
// un SetType propre en sortie. Les vieilles lignes sans type → 'normal'.
export function parseSetType(raw: string | null | undefined): SetType {
  return raw != null && (SET_TYPES as readonly string[]).includes(raw)
    ? (raw as SetType)   // on a vérifié → on peut affirmer le type
    : 'normal';          // valeur par défaut sûre
}

La signature (raw: string | null | undefined): SetType raconte toute l'histoire : « j'accepte du flou en entrée, je rends du propre en sortie ». C'est un point de passage obligé qui empêche les mauvaises valeurs de se propager dans le reste de l'app.

Regarde comment cette fonction enchaîne précisément les idées du module : elle reçoit une union (string | null | undefined), fait du narrowing (raw != null élimine d'abord les cas vides), puis vérifie à l'exécution que la valeur fait bien partie des choix autorisés (SET_TYPES.includes(raw)). Si tout passe, elle affirme le type avec raw as SetType — c'est le moment où elle dit à TypeScript « fais-moi confiance, j'ai vérifié ». Sinon, elle retombe sur une valeur par défaut sûre, 'normal'. Résultat : peu importe la saleté qui entre, ce qui sort est toujours un SetType valide. Tout le reste du code peut alors s'appuyer dessus sans douter.

Dans le même fichier, deux petites fonctions complètent l'histoire et montrent à quoi sert un type bien défini une fois qu'on l'a : elles répondent à des questions sur un SetType, de façon centralisée pour que toute l'app raisonne pareil.

// "Est-ce une série d'échauffement ?" — les warm-ups sont exclus
// du calcul du volume et du décompte des séries de travail.
export function isWarmupSet(type: SetType): boolean {
  return type === 'warmup';
}

// "Ce type de série accepte-t-il un RIR choisi ?"
// Seules les séries normales et les dropsets affichent les puces RIR.
export function acceptsRir(type: SetType): boolean {
  return type === 'normal' || type === 'dropset';
}

Chacune prend un SetType et retourne un boolean (vrai/faux). Parce que le type d'entrée est fermé à quatre valeurs, ces fonctions sont impossibles à appeler avec une saleté : le filet agit dès la signature.

Les génériques : un type avec un trou à remplir

Voici le concept qui intimide le plus à la lecture, et qui pourtant repose sur une idée toute simple. Un générique, c'est un type avec un trou à remplir. Pense à une boîte de rangement étiquetable : la boîte est toujours la même (un contenant), mais tu colles dessus une étiquette qui dit ce qu'il y a dedans. « Boîte de vis », « boîte de photos », « boîte de câbles ». Le contenant est générique ; l'étiquette précise le contenu.

En TypeScript, l'étiquette s'écrit entre chevrons <...>. Array<T> se lit « un tableau dont les éléments sont du type T » — le T est le trou. Remplis-le et tu obtiens un type précis :

Array<number>    // une liste de nombres : [1, 2, 3]
Array<string>    // une liste de textes : ['a', 'b']
Array<Exercise>  // une liste d'exercices

// `Exercise[]` et `Array<Exercise>` veulent dire EXACTEMENT la même chose.
// Deux écritures, un seul concept : "une liste de".

Le crochet [] et la forme Array<...> sont deux notations interchangeables pour « tableau de ». Dans types.ts, tu verras les deux : ExerciseSet[] ici, Array<WorkoutExerciseWithDetails> là.

La même idée de « contenant avec étiquette » se retrouve dans deux outils que tu utilises tous les jours. Promise<T> = « une valeur de type T qui arrivera plus tard » (une promesse de résultat asynchrone — on en parle dans le module sur l'asynchrone). Et useState<T> = « un état qui contient un T ». Justement, quand l'inférence ne suffit pas, on remplit le trou de useState nous-mêmes :

// Au départ, on n'a pas encore de workout chargé → null.
// Avec juste `null`, TypeScript ne peut pas deviner le type futur.
// On remplit donc le trou nous-mêmes avec une union :
const [workout, setWorkout] =
  useState<WorkoutWithDetails | null>(null);

// TypeScript sait maintenant :
//   workout    : WorkoutWithDetails | null
//   setWorkout : accepte un WorkoutWithDetails OU null
// Avant d'afficher workout.title, il FAUDRA narrower le null.

L'étiquette <WorkoutWithDetails | null> dit à React : « cet état contiendra soit un workout complet, soit rien ». C'est exactement le genre de cas où l'inférence ne peut pas se débrouiller seule, et où on annote explicitement le générique.

💡 Le concept

Quand tu vois des chevrons <...> juste après un nom de type, lis-les comme une étiquette : « le truc à gauche, rempli avec le truc entre chevrons ». Promise<User> = « une promesse de User ». Array<ExerciseSet> = « une liste de ExerciseSet ». Record<string, number> = « un dictionnaire de clés textuelles vers des nombres ». Tu n'as pas besoin de savoir fabriquer un générique pour lire du code — il suffit de remplir l'étiquette dans ta tête.

Les utility types : fabriquer un type à partir d'un autre

Plutôt que de redéfinir des interfaces presque identiques (ce qui serait pénible à maintenir : une modification à reporter partout), TypeScript fournit des utility types qui transforment des types existants. Ce sont eux-mêmes des génériques — des « machines à types » dont tu remplis le trou. Quatre reviennent en boucle. Commençons par les deux les plus fréquents dans Halterofit :

// Omit<T, 'champ'> = "le type T, MAIS sans ces champs".
// Pour CRÉER un workout, on n'a pas encore d'id ni de dates :
// la base les générera. On les RETIRE donc du type d'entrée.
export type CreateWorkout = Omit<Workout, 'id' | 'created_at' | 'updated_at'>;

// Partial<T> = "le type T, mais tous les champs deviennent facultatifs".
// Pour METTRE À JOUR, on n'envoie que les champs qui changent.
export type UpdateWorkout = Partial<Omit<Workout, 'id' | 'user_id' | 'created_at'>>;

UpdateWorkout combine les deux : d'abord Omit enlève les champs qu'on ne modifie jamais (id, propriétaire, date de création), puis Partial rend le reste facultatif. Lis ça de l'intérieur vers l'extérieur, comme des parenthèses en maths.

Cette lecture « de l'intérieur vers l'extérieur » est la clé pour ne pas paniquer devant un type imbriqué. Décortique Partial<Omit<Workout, 'id' | 'user_id' | 'created_at'>> étape par étape, en partant du centre :

  1. Le noyau, c'est Workout — toute la forme d'un workout.
  2. Omit<Workout, 'id' | 'user_id' | 'created_at'> : on retire trois champs. Il reste un workout privé de son id, de son propriétaire et de sa date de création.
  3. Partial<...> enveloppe le tout : chaque champ restant devient facultatif. Logique, puisqu'une mise à jour n'envoie que ce qui change.

Tu viens de lire un type « de l'intérieur vers l'extérieur ». C'est exactement la méthode qu'on généralise dans le module sur la méthode de lecture : trouver le noyau, puis dérouler les couches autour, une à la fois.

Deux autres utility types à reconnaître, même si Halterofit les utilise moins :

ÉcritureSe litExemple d'usage
Omit<T, K> « T sans les champs K » Créer un objet sans ses champs auto-générés.
Partial<T> « T avec tous les champs facultatifs » Mettre à jour partiellement.
Pick<T, K> « seulement les champs K de T » L'inverse d'Omit : ne garder que quelques champs.
Record<K, V> « un dictionnaire de clés K vers des valeurs V » Un cache Record<string, Exercise> indexé par id.

Pick et Omit sont deux faces d'une même pièce : Pick dit « garde ceux-là », Omit dit « enlève ceux-là ». Selon qu'il y a peu de champs à garder ou peu à retirer, on choisit le plus court à écrire. Quant à Record, il décrit un objet utilisé comme dictionnaire : Record<string, number>, c'est « des clés texte qui pointent vers des nombres », par exemple un comptage de répétitions par exercice.

any contre unknown : le filet qu'on coupe

Il existe un type spécial qui désactive le filet de sécurité : any. Une valeur typée any peut tout faire, accepter tout, être assignée à n'importe quoi — TypeScript arrête complètement de la vérifier. C'est commode sur le moment (« ça compile enfin ! »), et c'est précisément le problème : tu retrouves le JavaScript sans garde-fou, avec tous ses bugs silencieux, mais en croyant être protégé.

let donnee: any = recupererTruc();

donnee.title.toUpperCase();   // ✔ TypeScript ne dit rien...
donnee.foo.bar.baz;           // ✔ ... même si ça n'existe pas
donnee();                     // ✔ ... même si ce n'est pas une fonction
// → tout passe la compilation, tout PEUT planter à l'exécution.

Avec any, le compilateur lève les mains : « débrouille-toi ». Chaque ligne ci-dessus peut faire planter l'app à l'exécution, sans aucun avertissement préalable. any est une trappe par laquelle les bugs reviennent.

Quand tu reçois une valeur dont tu ignores vraiment la forme (typiquement aux frontières), le bon réflexe n'est pas any mais unknown. unknown dit aussi « je ne sais pas ce que c'est » — mais, contrairement à any, il t'oblige à vérifier avant d'utiliser. Le filet reste tendu :

let donnee: unknown = recupererTruc();

donnee.title;                 // ❌ erreur : TypeScript REFUSE tant que tu n'as pas vérifié

if (typeof donnee === 'string') {
  donnee.toUpperCase();       // ✔ ici, le narrowing a prouvé que c'est un string
}

unknown te force au narrowing avant de toucher à la valeur. C'est exactement l'esprit de parseSetType : accepter du flou, mais ne le laisser sortir qu'après vérification. unknown est honnête là où any est dangereux.

⚠️ Piège fréquent

Deux pièges classiques, à se répéter souvent. Un : abuser de any pour « faire taire » une erreur. Ça ne corrige rien — ça cache le problème en coupant le filet juste là où tu en avais le plus besoin. Préfère presque toujours unknown + une vérification. Deux : oublier qu'un champ marqué ? (ou typé avec | undefined / | null) peut ne pas exister. Lire weight?: number comme « un nombre » au lieu de « un nombre ou rien », c'est le bug d'écran blanc garanti le jour où la valeur est absente.

Lire un type intimidant : la méthode morceau par morceau

Tôt ou tard, tu tomberas sur une signature qui te fait peur au premier regard. Bonne nouvelle : un type long n'est jamais « difficile », il est juste composé. Tu n'as pas à le comprendre d'un coup ; tu le découpes en morceaux que tu connais déjà, et tu les recolles. Voici la méthode, appliquée à un type réel du fichier — un type dérivé pour mettre à jour une série :

export type UpdateExerciseSet = Partial<
  Omit<ExerciseSet, 'id' | 'workout_exercise_id' | 'created_at'>
>;

Procède dans cet ordre, sans sauter d'étape :

  1. Trouve le noyau. Au centre, il y a ExerciseSet — une série d'exercice. C'est le sujet de la phrase. Tout le reste va le transformer.
  2. Déroule la première couche. Omit<ExerciseSet, 'id' | 'workout_exercise_id' | 'created_at'> : on retire trois champs qu'on ne modifie jamais (l'id, le lien vers l'exercice parent, la date de création).
  3. Déroule la couche extérieure. Partial<...> rend tous les champs restants facultatifs.
  4. Recompose en une phrase. « UpdateExerciseSet, c'est une série dont on a retiré l'id, le lien parent et la date de création, et dont tous les autres champs sont facultatifs. » Autrement dit : « la liste des champs d'une série qu'on a le droit de modifier, tous optionnels. »

Remarque que tu n'as utilisé que des briques déjà vues : l'union (| entre les noms de champs), les génériques (les chevrons), et les utility types (Omit, Partial). Aucun type intimidant n'est autre chose qu'un assemblage de briques simples. La compétence n'est pas de « tout savoir », c'est de décomposer du centre vers l'extérieur. On en fait une vraie discipline dans le module sur la méthode de lecture.

🏋️ Dans Halterofit

Tout le fichier types.ts est une carte de tes données : User, Exercise, Workout, ExerciseSet, WorkoutPlan, PlanDay… Quand tu te demandes « qu'est-ce qu'un workout contient exactement ? », tu n'ouvres pas la base de données : tu lis l'interface. Le commentaire en tête du fichier précise même que ces types « collent au schéma Supabase pour la synchro » — donc ils décrivent à la fois ce qui vit en local et sur le serveur. On y trouve aussi SyncableTableName, une simple union des noms de tables synchronisées : une preuve de plus qu'un type, ici, sert d'abord à documenter une décision (« voici exactement les tables qui se synchronisent, pas une de plus »).

Hériter et enrichir : extends en pratique

Dernier motif courant avant les exercices : extends. Quand une interface en extends une autre, elle récupère tous ses champs et peut en ajouter. C'est l'équivalent objet de « pareil que l'autre, mais avec un peu plus ». Halterofit s'en sert pour passer d'une entité « brute » à une entité « enrichie » prête à afficher :

// Un Workout normal + la liste de ses exercices détaillés.
export interface WorkoutWithDetails extends Workout {
  exercises: Array<WorkoutExerciseWithDetails>;
}

// Un WorkoutExercise + l'exercice complet + ses séries.
export interface WorkoutExerciseWithDetails extends WorkoutExercise {
  exercise: Exercise;
  sets: ExerciseSet[];
}

WorkoutWithDetails a tout ce qu'a Workout (id, user_id, started_at…) plus un champ exercises. Et chaque exercice détaillé a tout ce qu'a WorkoutExercise plus l'objet Exercise complet et la liste de ses sets. On construit ainsi, par couches, un objet riche à partir d'objets simples.

Tu retrouves là toutes les idées du module emboîtées dans deux interfaces : extends pour hériter, Array<...> et [] pour les listes, et des références à d'autres types (Exercise, ExerciseSet) pour composer. Lire ces deux interfaces, c'est lire en miniature la structure de toute l'app.

✍️ Exercice de lecture

Voici trois déclarations tirées du même fichier. Lis-les sans regarder le corrigé :

export interface ExerciseHistoryEntry {
  workoutId: string;
  date: number;
  sets: ExerciseSet[];
}

export type CreateExerciseSet =
  Omit<ExerciseSet, 'id' | 'created_at' | 'updated_at'>;

const [workout, setWorkout] =
  useState<WorkoutWithDetails | null>(null);

Questions : (1) Que contient un ExerciseHistoryEntry, et comment lis-tu sets: ExerciseSet[] ? (2) Pourquoi CreateExerciseSet retire-t-il id, created_at et updated_at ? (3) Quel est le type de workout juste après cette ligne, et que dois-tu faire avant d'afficher workout.title ?

Voir le corrigé

(1) Un ExerciseHistoryEntry est une entrée d'historique : l'id du workout (workoutId, du texte), une date (date, un nombre — un timestamp), et sets: ExerciseSet[] qui se lit « une liste de ExerciseSet », donc toutes les séries de cet exercice ce jour-là. Le [] veut juste dire « tableau de ».

(2) Parce que ces trois champs sont générés automatiquement par la base au moment de l'insertion. Quand toi tu fournis les données pour créer une série, tu ne les connais pas encore. Omit les enlève donc du type d'entrée pour t'empêcher (et te dispenser) de les fournir. C'est le même motif que CreateWorkout.

(3) workout a le type WorkoutWithDetails | null : c'est une union, soit un workout complet, soit null (rien chargé). Avant d'écrire workout.title, tu dois narrower le null, par exemple if (workout != null) { /* ici workout est sûr */ }. Sinon TypeScript refuse, parce qu'on ne lit pas un champ sur quelque chose qui pourrait être null.

🧠 Quiz éclair

1. Que signifie weight?: number dans une interface ?

Le champ weight est facultatif : c'est soit un nombre, soit absent (undefined). Le ? marque l'optionnalité, et il faut gérer le cas « absent » avant d'utiliser la valeur.

2. Comment lis-tu le type Array<ExerciseSet> ?

« Une liste de ExerciseSet ». Les chevrons sont l'étiquette d'un générique : le contenant (Array) rempli avec le contenu (ExerciseSet). C'est strictement équivalent à ExerciseSet[].

3. À quoi sert Omit<Workout, 'id'>, et comment lit-on Partial<Omit<...>> ?

Omit<Workout, 'id'> fabrique un type identique à Workout mais sans le champ id. Quand c'est imbriqué, on lit de l'intérieur vers l'extérieur : d'abord Omit retire des champs, puis Partial rend le reste facultatif.

4. Pourquoi any est-il dangereux, et que vaut-il mieux utiliser ?

any désactive le filet : TypeScript arrête de vérifier la valeur, donc les bugs reviennent silencieusement à l'exécution. Préfère unknown, qui dit aussi « je ne sais pas » mais t'oblige à vérifier (narrower) avant d'utiliser la valeur.

5. Dans const [x, setX] = useState(0), quel est le type de x et pourquoi ?

x est inféré comme number, parce que la valeur initiale est 0. On n'a rien annoté : c'est l'inférence qui déduit le type à partir de la valeur. setX n'accepte donc que des nombres.

À retenir

TypeScript = JavaScript + un filet de sécurité qui disparaît à l'exécution (effacement de types). interface = forme d'un objet · type = surtout pour les unions et les types dérivés · ? = facultatif (donc « ou undefined ») · | = union (« ou »), et un if fait du narrowing pour prouver quel membre on tient · <...> = générique, une étiquette à remplir (Array, Promise, useState) · Omit / Partial / Pick / Record = fabriquer un type à partir d'un autre, à lire de l'intérieur vers l'extérieur · extends = hériter des champs · évite any (préfère unknown). Devant un type intimidant : trouve le noyau, déroule les couches, recompose en une phrase.

🔄 Transférable

Ces mêmes idées existent dans presque tous les langages typés (Kotlin, Swift, Rust, C#…) : interfaces, unions, génériques, optionnels, héritage. Les mots changent un peu, le fond non. Et surtout, le réflexe « lire les types d'abord pour comprendre les données » et la méthode « décomposer un type du centre vers l'extérieur » accélèrent ta lecture de n'importe quel code typé, partout, bien au-delà de Halterofit.