Accueil / Articles / Expliqué TypeScript 6.0 : les changements du compilateur et la maîtrise des génériques

Expliqué TypeScript 6.0 : les changements du compilateur et la maîtrise des génériques

Il explique en détail les modifications apportées au compilateur de transition dans TypeScript 6.0 et montre comment utiliser les génériques pour créer du code de niveau type plus sûr et plus réutilisable.

2691 mots

TypeScript évolue en même temps dans deux directions : le compilateur lui-même se renforce et gagne en vitesse, tandis que l’outil le plus puissant du système de types — les génériques — reste la clé pour écrire du code qui reste sécurisé même à grande échelle. Comprendre les changements internes de TypeScript 6.0 et maîtriser les génériques vous permettent de savoir comment écrire du TypeScript à la fois résistant aux évolutions futures et véritablement réutilisable. Commencez par les modifications du compilateur, car elles définissent la base sur laquelle tout code utilisant abondamment des génériques fonctionnera.

Une version de transition vers un compilateur natif

TypeScript 6.0 n’est pas avant tout une version destinée à vous offrir de nouvelles fonctionnalités — c’est plutôt un pont. L’équipe de TypeScript l’a clairement décrite comme une version transitoire : la dernière basée sur le code original en JavaScript avant que TypeScript 7.0 ne soit publié sous forme de réécriture complète en Go. Si votre mise à jour vers 6.0 semble particulièrement discrète, c’est intentionnel. La plupart des changements majeurs sont réservés à 7.0.

Quels changements réels ?

Diverses modifications concrètes sont introduites en 6.0 :

  • Le mode strict est désormais la valeur par défaut pour les nouveaux projets. Vous n’avez plus besoin d’activer "strict": true — au contraire, les bases de code utilisant un typage souple doivent explicitement définir "strict": false. L’intention de l’équipe est claire : ils veulent que vous résolviez les problèmes de type sous-jacents plutôt que de les masquer.
  • L’array types est vide par défaut. Auparavant, TypeScript chargeait automatiquement tous les packages situés sous node_modules/@types. Désormais, rien n’est chargé à moins que vous ne le spécifiiez explicitement, ce qui peut réduire significativement les temps de compilation dans les projets plus importants.
  • target et module ont pour valeurs par défaut respectivement es2025 et esnext. Générer du code compatible avec ES5 est en pratique obsolète.
  • Les types de l’API Temporal native sont désormais disponibles, vous permettant de gérer les dates et les heures de manière statique et sécurisée sans avoir recours à des bibliothèques comme date-fns ou Luxon. Il s’agit de la fonctionnalité principale que les développeurs réclamaient depuis longtemps.
  • // Before: juggling Date math and timezone offsets manually
    const deadline = new Date(Date.now() + 86400000);
    
    // TypeScript 6.0: Temporal makes intent explicit
    const now = Temporal.Now.zonedDateTimeISO("Asia/Kolkata");
    const deadline = now.add({ hours: 24 });
    
    • L’inférence s’améliore pour les fonctions qui n’utilisent pas this, et TypeScript prend désormais en charge les importations de sous-chemins préfixés par #, ainsi que la combinaison de moduleResolution: bundler avec module: commonjs — une association qui n’était pas possible auparavant.
    • La bibliothèque standard intègre désormais Map.getOrInsert, Map.getOrInsertComputed ainsi qu’une fonction intégrée RegExp.escape(), ce qui élimine le besoin d’outils personnalisés pour l’échappement.
    • --baseUrl est déprécié. Migratez les alias de chemins vers paths dans votre tsconfig avant que baseUrl ne soit complètement supprimé dans la version 7.0.

    Pourquoi ce caractère transitoire est important

    D’après l’équipe de TypeScript, la raison de cette version est de préparer les développeurs à 7.0, qui intègre un compilateur basé sur Go permettant des builds incrémentaux 40 à 60 % plus rapides que ceux actuels. En pratique, cette version constitue une tâche à effectuer dès maintenant : éliminez vos avertissements de dépréciation dès aujourd’hui, afin que 7.0 apparaisse comme une amélioration de performance gratuite plutôt qu’une migration perturbatrice, surtout sur des environnements Node.js modernes.

    Que faire avant l’arrivée de 7.0

    • Exécutez tsc --init et examinez les nouveaux erreurs liées au mode strict qu’il affiche dès le début.
    • Transférez la configuration depuis baseUrl vers paths maintenant, avant sa suppression.
    • Déclarez explicitement votre tableau types au lieu de compter sur un chargement automatique.
    • Démarrez l’introduction de Temporal dans des parties du code à faible risque pour vous y familiariser.

    TypeScript a une longue histoire de transformation des meilleures pratiques actuelles en normes par défaut à l’avenir. La version 6.0 représente cette phase de transition calme avant l’avènement d’une vitesse d’exécution native — utilisez-la pour mettre en ordre votre configuration afin que, lorsque la version 7.0 sera disponible, le changement soit à peine perceptible.

    De la discipline de configuration au raisonnement au niveau des types

    Les mises à jour de version et les paramètres du compilateur ne constituent qu’une partie de l’art d’écrire du TypeScript solide. L’autre partie consiste à savoir comment structurer ses propres types de manière que le compilateur puisse réellement vous aider — et c’est là que nous arrivons aux génériques, sans doute la fonctionnalité qui distingue les développeurs qui luttent contre le système de types de ceux qui l’utilisent avec aisance.

    Presque tous les développeurs TypeScript arrivent au même carrefour. Au début, ce langage donne l’impression d’un bibliothécaire méticuleux qui surveille vos épaules : vous créez une interface pour User, une autre pour Product, puis une autre pour BlogPost, et tout reste ordonné et sécurisé.

    Puis la base de code grandit.

    Vous avez besoin d’une fonction pour récupérer un User depuis une API, puis une autre pour Product, et encore une autre pour BlogPost. Ou bien vous essayez de contourner le problème en écrivant un wrapper commun, mais vous vous retrouvez submergé par des erreurs de compilation, et finissez par utiliser any partout simplement pour faire disparaître les erreurs rouges — sans savoir que vous supprimez ainsi le filet de sécurité que TypeScript était censé vous offrir.

    C’est précisément ce mur qui bloque de nombreux développeurs. Le dépasser signifie comprendre réellement les génériques.

    Les génériques ne sont pas un subterfuge syntaxique à mémoriser pour un entretien. Ce sont l’ossature structurelle de codes réutilisables, faciles à maintenir et évolutifs. Une fois que le concept est compris, on cesse d’écrire manuellement du code générique répétitif pour commencer à concevoir des systèmes comme le ferait un ingénieur expérimenté.

    Créer le bon modèle mental

    Oubliez temporairement la conceptualisation formelle en informatique et réfléchissez au comportement d’une fonction JavaScript ordinaire. On n’encode jamais de valeur spécifique directement dans le corps de la fonction :

    // Hardcoded: Only works for one specific person
    function greetRahul() {
      return "Hello, Rahul!";
    }
    
    // Dynamic: Uses a parameter as a placeholder for data
    function greet(name: string) {
      return `Hello, ${name}!`;
    }
    

    Le paramètre name n’est rien de plus qu’un substitut pour une valeur qui sera fournie ultérieurement, au moment de l’appel.

    Un générique fonctionne exactement de la même manière — sauf qu’au lieu de représenter une valeur, il représente un type. Les fonctions, les classes et les interfaces peuvent toutes accepter des types en tant qu’arguments, de la même façon que les fonctions ordinaires acceptent des valeurs en tant qu’arguments.

    Imaginez une simple boîte en carton pour l’expédition. À l’usine, personne ne sait encore si elle contiendra un ordinateur portable, une paire de chaussures ou une tasse en céramique — c’est simplement un conteneur générique, Box<T>. Si vous y mettez un ordinateur portable, elle devient Box<Laptop> ; si vous y mettez des chaussures, elle devient Box<Shoes>. La boîte elle-même est indifférente à son contenu, mais on sait toujours ce qu’il y a dedans : en ouvrant une Box<Laptop>, on sait qu’on peut l’allumer ; en ouvrant une Box<Shoes>, on sait qu’on peut les porter. Rien n’est laissé au hasard.

    L’élimination du problème de duplication grâce aux génériques

    Imaginons une fonction utilitaire qui enveloppe des données accompagnées de métadonnées telles qu’une date et une ID générée. Sans génériques, vous devez écrire un wrapper presque identique pour chaque modèle de votre application :

    // The Brute-Force Approach: Duplicate functions for every entity
    interface User {
      name: string;
      role: string;
    }
    
    interface Product {
      title: string;
      price: number;
    }
    
    function wrapUser(item: User) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    
    function wrapProduct(item: Product) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    

    C’est une violation directe du principe DRY : vingt modèles de données signifient vingt fonctions d’enveloppement presque identiques.

    La solution de facilité consiste à utiliser any pour éliminer cette duplication :

    function wrapItem(item: any) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    
    const wrapped = wrapItem({ name: "Alex", role: "Admin" });
    
    // TypeScript has no idea what 'wrapped.data' is!
    // Autocomplete is dead. Typos will crash in production.
    console.log(wrapped.data.nonExistentProperty); // Compiles without error, fails at runtime!
    

    Le compilateur cesse de signaler des erreurs, mais vous payez ce silence par la perte de la sécurité des types, de l’autocomplétion et du refactoring sécurisé — précisément les fonctionnalités que TypeScript est conçu pour vous offrir.

    La meilleure approche consiste à exprimer cette même fonction utilitaire de manière générique :

    function wrapItem<T>(item: T) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    

    La syntaxe <T> effectue trois choses en même temps. Premièrement, elle déclare une variable de type nommée T que cette fonction pourra utiliser. Deuxièmement, en indiquant le paramètre sous la forme (item: T), on signifie que le type de l’argument sera celui que T prendra lors du appel de la fonction. Troisièmement, comme le type de retour fait également référence à T, le type exact des entrées est transmis tel quel aux sorties, sous la forme de data: T dans ce cas.

    const userResult = wrapItem({ name: "Alex", role: "Admin" });
    
    // TypeScript automatically infers that T is { name: string; role: string }
    console.log(userResult.data.name); // Full autocomplete works!
    console.log(userResult.data.invalidProp); // Error: Property 'invalidProp' does not exist!
    

    Lorsque l’on appelle cette fonction avec un objet de type User, TypeScript déduit automatiquement la valeur de T — aucune annotation n’est nécessaire — et conserve cette structure déduite jusqu’à la propriété data renvoyée. Ainsi, userResult.data.name bénéficie d’une complétion automatique et d’un contrôle de type complets, contrairement à la confiance aveugle que any aurait imposée.

    Ajout de limites avec des contraintes

    Laisser T complètement sans contraintes fonctionne bien pour des outils génériques de type identité, mais de nombreuses fonctions réelles doivent faire des hypothèses sur la forme de leurs entrées. Plutôt que d’accepter littéralement n’importe quoi, on souhaite souvent préciser "n’importe quel type, à condition qu’il ait cette forme." C’est ce que permettent les contraintes génériques, en utilisant le mot-clé extends pour définir ce que T est autorisé à être.

    Considérons une fonction destinée à afficher l’ID unique d’une entité :

    // This causes a compiler error!
    function printId<T>(entity: T) {
      console.log(entity.id);
      // Error: Property 'id' does not exist on type 'T'.
    }
    

    Ce code ne compile pas, car rien n’indique à TypeScript que T possède un champ id. T pourrait tout aussi bien être un number, un boolean, null ou un objet vide, aucun de ces types ne garantissant l’existence d’un champ .id.

    La solution consiste à restreindre T à une forme qui inclut un id:

    interface HasId {
      id: string | number;
    }
    
    function printId<T extends HasId>(entity: T) {
      // Safe! TypeScript guarantees entity has an 'id' property.
      console.log(`Entity ID: ${entity.id}`);
      return entity;
    }
    
    // Works perfectly:
    printId({ id: 101, name: "Database Record" });
    printId({ id: "usr_99", email: "dev@example.com" });
    
    // Fails at compile time before hitting production:
    printId({ name: "Unsaved Item" });
    // Error: Argument of type '{ name: string; }' is not assignable to parameter of type 'HasId'.
    

    En écrivant T extends HasId, on indique au compilateur qu’il peut accepter n’importe quel type, à condition qu’il remplisse l’exigence minimale d’avoir une propriété id.

    Recherches sécurisées par type avec keyof

    Une source classique d’erreurs en JavaScript est l’accès à une propriété qui n’existe pas, souvent à cause d’une faute de frappe comme user.fristName au lieu de user.firstName. En associant les génériques à l’opérateur keyof, on peut créer des outils où ce type d’erreur devient structurellement impossible.

    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
    
    const employee = {
      id: 42,
      name: "Sarah Connor",
      department: "Security",
      isActive: true,
    };
    
    // Autocomplete offers: "id" | "name" | "department" | "isActive"
    const empName = getProperty(employee, "name"); // Type inferred as: string
    const empActive = getProperty(employee, "isActive"); // Type inferred as: boolean
    
    // Typos are caught immediately:
    const badProp = getProperty(employee, "deparment");
    // Error: Argument of type '"deparment"' is not assignable to parameter of type '"id" | "name" | "department" | "isActive"'.
    

    Voici pourquoi ce schéma est si puissant :

    • T représente la structure de l’objet sur lequel on travaille.
  • keyof T produit une union de toutes les clés valides sur T, comme par exemple "id" | "name" | "department" | "isActive".
  • K extends keyof T impose que key soit l’une de ces chaînes littérales, et rien d’autre.
  • T[K] fait en sorte que le type de retour corresponde au type de valeur exact stocké sous cette clé.
  • Ce qui ressemble à une petite fonction d’aide est en réalité un contrat de compilation qui élimine complètement les erreurs de frappe dans les noms de propriétés.

    Concevoir un client API réutilisable

    Au-delà des outils isolés, les génériques se révèlent particulièrement utiles dans le code à grande échelle. Presque toutes les applications web communiquent avec un backend, et la plupart des API REST enveloppent leurs réponses dans une structure JSON cohérente :

    {
      "status": "success",
      "statusCode": 200,
      "data": { ... },
      "message": "Operation successful"
    }
    

    Au lieu d’écrire manuellement un type de réponse distinct pour chaque endpoint, vous pouvez définir une enveloppe générique et la réutiliser partout :

    // 1. The Generic Contract
    interface ApiResponse<TData> {
      status: "success" | "error";
      statusCode: number;
      data: TData;
      message?: string;
    }
    
    // 2. The Pagination Envelope
    interface PaginatedList<TItem> {
      items: TItem[];
      totalCount: number;
      page: number;
      pageSize: number;
    }
    

    Avec ce contrat en place, le client HTTP lui-même devient remarquablement compact et réutilisable :

    async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    }
    
    // Concrete Domain Models
    interface UserProfile {
      id: string;
      username: string;
      email: string;
    }
    
    interface OrderHistory {
      orderId: string;
      totalAmount: number;
      currency: string;
    }
    
    // Usage Example 1: Fetching a single user
    async function loadUser() {
      const response = await fetchApi<UserProfile>("/api/v1/profile");
    
      // Fully typed:
      console.log(response.data.username);
    }
    
    // Usage Example 2: Fetching a paginated list of orders
    async function loadOrders() {
      const response = await fetchApi<PaginatedList<OrderHistory>>("/api/v1/orders");
    
      // Fully typed nested structures:
      response.data.items.forEach(order => {
        console.log(`Order #${order.orderId}: ${order.totalAmount}`);
      });
    }
    

    Les avantages sont importants : sans devoir créer une fonction fetch séparée pour chaque route, chaque endpoint hérite automatiquement d’une sécurité de type complète du côté de la demande au côté de la réponse, ainsi que d’un autocomplétion précis et d’une maintenance bien plus simple à long terme.

    Intégration des génériques dans les composants UI réutilisables

    Le même principe s’applique naturellement à la couche des composants. Si vous créez des interfaces en React, Vue ou avec des Web Components classiques, il est probable que vous ayez déjà écrit un menu déroulant, un tableau ou une liste à un moment donné. Sans génériques, ces composants réutilisables ont tendance à ne plus fonctionner dès que l’on a besoin qu’ils gèrent des données de formes différentes.

    Prenons par exemple un composant de tableau générique développé en React :

    interface TableProps<T> {
      data: T[];
      renderRow: (item: T, index: number) => React.ReactNode;
      keyExtractor: (item: T) => string | number;
    }
    
    export function GenericTable<T>({ data, renderRow, keyExtractor }: TableProps<T>) {
      return (
        <table>
          <tbody>
            {data.map((item, index) => (
              <tr key={keyExtractor(item)}>
                {renderRow(item, index)}
              </tr>
            ))}
          </tbody>
        </table>
      );
    }
    

    Son utilisation se fait comme ceci :

    interface Customer {
      id: string;
      fullName: string;
      loyaltyPoints: number;
    }
    
    const customers: Customer[] = [
      { id: "c1", fullName: "Elena Rostova", loyaltyPoints: 450 },
      { id: "c2", fullName: "David Miller", loyaltyPoints: 1200 },
    ];
    
    function CustomerList() {
      return (
        <GenericTable
          data={customers}
          keyExtractor={(customer) => customer.id} // customer is inferred as Customer!
          renderRow={(customer) => (
            <>
              <td>{customer.fullName}</td>
              <td>{customer.loyaltyPoints} pts</td>
            </>
          )}
        />
      );
    }
    

    On remarque qu’il n’y a ni conversion de type avec as Customer, ni utilisation de any, et aucune nécessité de deviner. Si un collègue renomme plus tard fullName en name sur l’interface Customer, TypeScript indiquera immédiatement tous les endroits dans l’interface utilisateur qui doivent encore être mis à jour.

    Garantir la lisibilité des génériques : trois principes directeurs

    Les génériques sont puissants, mais cette puissance incite à une sur-ingénierie. Les bases de code finissent parfois par contenir des monstres tels que ProcessData<T, Record<string, T, U, V W extends keyof>>. Cet état embrouillé est souvent appelé « soupe générique », et il transforme un code autrement simple en une énigme que personne ne veut affronter.

    Trois habitudes permettent de garder les génériques lus facilement plutôt que compliqués. Un anti-modèle courant à éviter est l’introduction d’un paramètre de type qui n’apparaît qu’une seule fois dans la signature d’une fonction :

    // ❌ OVER-ENGINEERED: T is only used once
    function logMessage<T extends string>(message: T): void {
      console.log(message);
    }
    
    // ✅ CLEAN & DIRECT: No generic required
    function logMessage(message: string): void {
      console.log(message);
    }
    

    Si un paramètre de type n’est utilisé qu’une seule fois, il ne justifie généralement pas sa complexité et peut souvent être remplacé par un type concret.

    En résumé : un changement de mentalité

    Rédiger du code qui fonctionne pour un type de données spécifique fait de vous un développeur. Rédiger du code qui reste réutilisable, composable et sûr en termes de types quel que soit le type de données est ce qui distingue un développeur senior.

    Les génériques vous éloignent du code répétitif et fragile pour vous orienter vers des architectures flexibles et résilientes par conception. La prochaine fois que vous vous surprenez à dupliquer une interface, à cloner une fonction d’aide ou à utiliser any par défaut, arrêtez-vous et demandez-vous si cette valeur ne pourrait pas plutôt devenir un paramètre de type. Une fois cet instinct devenu automatique, vous cessez simplement d’écrire plus rapidement en TypeScript pour commencer à construire des systèmes largement immunisés contre les imprévus en temps de exécution.

    Lectures complémentaires