TypeScript Intermédiaire

Maîtriser les Types Avancés de TypeScript pour des Applications Robustes

Explorez les systèmes de types sophistiqués de TypeScript pour écrire du code production-ready. Apprenez à utiliser les génériques, les types utilitaires et les patterns avancés pour construire des applications maintenables et type-safe.

Preparetoi.academy 30 min

Les Génériques : Fondations de la Réutilisabilité

Définition
Les génériques permettent de créer des composants, fonctions et classes flexibles qui fonctionnent avec plusieurs types tout en conservant la sécurité des types. Un générique est un paramètre de type qui sera spécifié lors de l'utilisation du code, permettant une abstraction sur le type sans perdre l'information de typage.

Explication détaillée
En développement web professionnel, vous rencontrerez souvent le besoin de créer des structures réutilisables. Sans génériques, vous seriez forcé de dupliquer du code ou d'utiliser any, ce qui annulerait les avantages du typage. Les génériques résolvent ce problème en permettant au compilateur de maintenir l'intégrité des types à travers des abstractions. Ils sont essentiels pour créer des API propres, maintenables et documentées.

// Exemple basique : fonction générique
function identité<T>(valeur: T): T {
  return valeur;
}

const nombre = identité<number>(42); // nombre: number
const texte = identité<string>("Hello"); // texte: string

// Générique avec contraintes
interface Identifiable {
  id: number;
  nom: string;
}

function obtenirId<T extends Identifiable>(objet: T): number {
  return objet.id;
}

// Génériques dans les classes
class Repository<T> {
  private items: T[] = [];

  ajouter(item: T): void {
    this.items.push(item);
  }

  obtenir(index: number): T | undefined {
    return this.items[index];
  }

  obtenirTous(): T[] {
    return [...this.items];
  }
}

interface Utilisateur {
  id: number;
  nom: string;
  email: string;
}

const repUtilisateurs = new Repository<Utilisateur>();
repUtilisateurs.ajouter({ id: 1, nom: "Alice", email: "alice@example.com" });
Cas d'Usage Exemple Bénéfice
Fonctions réutilisables function map<T, U>(items: T[], fn: (t: T) => U): U[] Évite la duplication
Conteneurs de données class Cache<T> Type-safe pour tout contenu
Contraintes de type <T extends { id: number }> Garantit les propriétés requises
Génériques imbriqués Promise<Array<T>> Composition flexible

💡 Astuce professionnelle
Utilisez les contraintes de génériques (extends) pour documenter implicitement votre API. Au lieu de laisser un développeur utiliser votre fonction avec n'importe quel type, contraignez-le explicitement. Cela réduit les bugs et améliore l'expérience développeur.

⚠️ Attention critique
Ne faites pas de suppositions sur les propriétés d'un générique sans les contraindre. T seul ne vous permet que les opérations communes à tous les types (assignment et typeof). Utilisez toujours extends pour accéder à des propriétés spécifiques.

Types Utilitaires : Transformation et Manipulation des Types

Définition
Les types utilitaires sont des transformations de types prédéfinies par TypeScript qui permettent de créer de nouveaux types en manipulant les types existants. Ils offrent des opérations comme l'extraction, l'exclusion, la sélection et la transformation de propriétés sans dupliquer le code.

Explication détaillée
Dans une application professionnelle, vous devez souvent créer des variantes d'un type existant : une version en lecture seule, sans certaines propriétés, ou avec toutes les propriétés optionnelles. Les types utilitaires évitent la répétition et gardent votre code DRY. TypeScript fournit des outils puissants comme Partial, Pick, Omit, Record et Exclude qui peuvent être composés pour créer des structures de type exactement adaptées à vos besoins.

// Types utilitaires courants
interface Produit {
  id: number;
  nom: string;
  prix: number;
  description: string;
  stock: number;
}

// Partial : toutes propriétés optionnelles
type ProduitPartiel = Partial<Produit>;
const update: ProduitPartiel = { nom: "Nouveau nom" }; // Valide

// Pick : sélectionner certaines propriétés
type ProduitResume = Pick<Produit, "id" | "nom" | "prix">;
const resume: ProduitResume = { 
  id: 1, 
  nom: "Laptop", 
  prix: 999.99 
}; // Correct

// Omit : exclure certaines propriétés
type ProduitSansStock = Omit<Produit, "stock" | "description">;

// Record : créer un objet avec clés typées
type StatusProduit = "actif" | "inactif" | "soldout";
type StatutsConfig = Record<StatusProduit, { couleur: string; icone: string }>;

const config: StatutsConfig = {
  actif: { couleur: "vert", icone: "✓" },
  inactif: { couleur: "gris", icone: "○" },
  soldout: { couleur: "rouge", icone: "✗" }
};

// Readonly : propriétés immuables
type ProduitImmutable = Readonly<Produit>;

// Extract et Exclude : manipulation d'unions
type Keys = "nom" | "prix" | "stock";
type NumericKeys = Extract<Keys, "prix" | "stock">; // "prix" | "stock"
type TextKeys = Exclude<Keys, "prix" | "stock">; // "nom"

// ReturnType et Parameters : analyse de fonctions
type MaFonction = (x: number, y: string) => boolean;
type Params = Parameters<MaFonction>; // [x: number, y: string]
type ReturnVal = ReturnType<MaFonction>; // boolean
Type Utilitaire Signature Cas d'Usage
Partial<T> Rend toutes propriétés optionnelles Updates partiels
Pick<T, K> Sélectionne propriétés spécifiées DTOs, résumés
Omit<T, K> Exclut propriétés spécifiées Formulaires sans certains champs
Record<K, T> Crée objet avec clés typées Configurations, mappages
Readonly<T> Rend propriétés immuables Données de configuration
Extract<T, U> Extrait types communs Filtrage d'unions
Exclude<T, U> Exclut types spécifiés Contrainte de types

💡 Astuce professionnelle
Composez les types utilitaires pour créer des abstractions puissantes. Par exemple, créez un type APIResponse<T> qui combine Record, Omit et Partial pour standardiser vos réponses API. Cela réduit les bugs liés aux erreurs de structure.

⚠️ Attention critique
Les types utilitaires créent une nouvelle référence de type. Deux types créés avec Omit à partir du même source mais avec des propriétés différentes ne sont pas compatibles entre eux, même s'ils partagent des propriétés. Soyez conscient des assignabilités implicites.

Types Union et Types Intersection : Composition Avancée

Définition
Les types union (|) permettent qu'une valeur soit l'un de plusieurs types possibles, tandis que les types intersection (&) créent un type combinant tous les types spécifiés. Ces compositions permettent d'exprimer des structures complexes et des contrats flexibles entre composants.

Explication détaillée
En développement web réel, vous devez gérer des scénarios où une valeur peut prendre plusieurs formes (union) ou où une entité combine plusieurs interfaces (intersection). Les unions sont parfaites pour les énumérations structurelles et les états multiples, tandis que les intersections modelent l'héritage et le mixage de comportements. Maîtriser ces concepts est crucial pour écrire du code expressif qui reflète votre domaine métier.

// Union types : états multiples
type Reponse = string | number | boolean;

// Union discriminée : pattern professionnel crucial
type Resultat<T> = 
  | { succes: true; donnees: T }
  | { succes: false; erreur: string; code: number };

function traiterResultat<T>(result: Resultat<T>) {
  if (result.succes) {
    console.log("Données:", result.donnees);
  } else {
    console.error(`Erreur ${result.code}: ${result.erreur}`);
  }
}

// Type intersection : combination de comportements
interface Personne {
  nom: string;
  age: number;
}

interface Employe {
  numeroEmploye: string;
  departement: string;
}

type EmployePersonne = Personne & Employe;

const employe: EmployePersonne = {
  nom: "Alice",
  age: 30,
  numeroEmploye: "EMP001",
  departement: "IT"
};

// Unions avec propriétés littérales (pattern matching)
type Notification = 
  | { type: "email"; destinataire: string; sujet: string }
  | { type: "sms"; numero: string; message: string }
  | { type: "push"; titre: string; corps: string };

function envoyer(notif: Notification): void {
  switch (notif.type) {
    case "email":
      console.log(`Email à ${notif.destinataire}`);
      break;
    case "sms":
      console.log(`SMS au ${notif.numero}`);
      break;
    case "push":
      console.log(`Push: ${notif.titre}`);
      break;
  }
}

// Intersection pour middleware en Express
interface Middleware {
  nom: string;
  executer(): void;
}

interface LoggerMixin {
  logger(msg: string): void;
}

type MiddlewareAvecLog = Middleware & LoggerMixin;

const myMiddleware: MiddlewareAvecLog = {
  nom: "Auth",
  executer() { console.log("Executing"); },
  logger(msg: string) { console.log(`[LOG] ${msg}`); }
};
Pattern Utilité Exemple Réel
Union simple Alternatives de type string | number
Union discriminée Pattern matching type-safe État d'une requête API
Intersection Mixins et composition Composant + Plugin
Union de littéraux État énuméré "loading" | "error" | "success"
Intersection recursive Structures imbriquées Arbres avec des nœuds typés

💡 Astuce professionnelle
Préférez les unions discriminées aux unions simples en production. Au lieu de type Etat = "actif" | "inactif" sur une interface large, utilisez des objets distincts avec une propriété type commune. Cela force les développeurs à gérer tous les cas et rend le code plus lisible et maintenable.

⚠️ Attention critique
Avec les intersections, les propriétés conflictuelles deviennent never. Par exemple, { a: string } & { a: number } crée un type inutilisable. Vérifiez toujours la compatibilité de vos intersections. Pour les unions, TypeScript ne peut pas inférer automatiquement le discriminateur si vous n'êtes pas explicite.

Génériques Avancés et Conditional Types

Définition
Les conditional types permettent de sélectionner un type en fonction d'une condition. Syntaxe : T extends U ? X : Y. Combinés aux génériques, ils créent des transformations de types sophistiquées qui adaptent le résultat en fonction du type d'entrée, offrant une flexibilité et une expressivité maximales.

Explication détaillée
Les conditional types sont la caractéristique la plus puissante et avancée de TypeScript pour la manipulation de types au niveau du système de types. En environnement professionnel, vous utiliserez des conditional types pour créer des outils qui s'adaptent intelligemment aux types. Par exemple, une fonction qui retourne un tableau si vous lui passez un type non-tableau, ou qui retourne l'élément si vous passez un tableau. Cela permet de créer des APIs intuitives et type-safe qui anticipent les intentions du développeur.

// Conditional types basiques
type Aplatir<T> = T extends Array<infer U> ? U : T;

type A = Aplatir<number[]>; // number
type B = Aplatir<string>; // string

// Cas avancé : déterminer le type de réponse basé sur le statut
type ResponseType<T extends number> = 
  T extends 200 ? "success" 
  : T extends 404 ? "not-found"
  : T extends 500 ? "error"
  : "unknown";

type R200 = ResponseType<200>; // "success"
type R404 = ResponseType<404>; // "not-found"

// infer : extraire des informations de types
type ExtractArrayElement<T> = T extends Array<infer U> ? U : never;

type First = ExtractArrayElement<[string, number, boolean]>; // string | number | boolean
type Second = ExtractArrayElement<string>; // never

// Cas professionnel : API générique basée sur le verbe HTTP
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";

type RequestBody<M extends HTTPMethod> = 
  M extends "GET" | "DELETE" ? undefined
  : M extends "POST" | "PUT" ? Record<string, any>
  : never;

type APIEndpoint<Method extends HTTPMethod, ResponseData> = {
  method: Method;
  body: RequestBody<Method>;
  response: ResponseData;
};

const getEndpoint: APIEndpoint<"GET", { users: string[] }> = {
  method: "GET",
  body: undefined, // Forcé à undefined
  response: { users: [] }
};

const postEndpoint: APIEndpoint<"POST", { id: number }> = {
  method: "POST",
  body: { nom: "Alice", email: "alice@example.com" }, // Objet requis
  response: { id: 1 }
};

// Mapped types avec conditional types
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

interface Utilisateur {
  nom: string;
  email: string;
  age: number;
}

type GettersUtil = Getters<Utilisateur>;
// Résultat: { getNom: () => string; getEmail: () => string; getAge: () => number }

// Fonction qui adapte son retour selon l'entrée
function emballer<T>(valeur: T): T extends Array<any> ? T : [T] {
  if (Array.isArray(valeur)) {
    return valeur as any;
  }
  return [valeur] as any;
}

const arr = emballer([1, 2, 3]); // [1, 2, 3]
const wrapped = emballer("hello"); // ["hello"]
Technique Application Bénéfice
infer Extraction d'éléments Décomposition de types complexes
Conditional chaîné Logique multi-branches Sélection précise selon contexte
Mapped types Transformation propriétés Génération d'interfaces dérivées
Distributive conditionals Unions itérées Traitement élément par élément
Recursive types Structures imbriquées Parsing de structures profondes

💡 Astuce professionnelle
Utilisez les conditional types pour créer des helpers qui documentent l'intension du code. Un conditional type bien nommé agit comme une assertion sémantique. Par exemple, IsString<T> indique clairement votre intention mieux qu'une condition lambda lambda.

⚠️ Attention critique
Les conditional types avec infer distribuent sur les unions. T extends Array<infer U> ? U : never sur number[] | string[] retourne number | string, pas les deux à la fois. Utilisez [T] extends [Array<infer U>] si vous voulez éviter la distribution. De plus, l'ordre de chaînage est crucial; des conditions trop générales en début bloqueront les plus spécifiques.

Patterns Professionnels : Builder, Decorator et Middleware Types

Définition
Les patterns de type sont des approches structurées pour résoudre des problèmes récurrents en TypeScript. Ils combinent génériques, conditional types, et types utilitaires pour créer des architectures élégantes et maintenables. Ces patterns émergent des meilleures pratiques en environnement production et répondent à des défis concrets de développement web.

Explication détaillée
Au-delà des concepts fondamentaux, les développeurs TypeScript professionnels appliquent des patterns reconnaissables qui facilitent la collaboration et la maintenance. Le pattern Builder permet une construction fluide d'objets complexes avec validation progressive. Le pattern Decorator ajoute des responsabilités dynamiques aux classes. Le pattern Middleware crée des pipelines de traitement composables. Ces patterns, implémentés correctement en TypeScript, transforment du code fragmenté en architecture claire, documentée par les types eux-mêmes.

// Pattern Builder : construction d'objets complexes
class QueryBuilder<T> {
  private conditions: Array<(item: T) => boolean> = [];
  private limit?: number;
  private offset?: number;

  where(predicate: (item: T) => boolean): this {
    this.conditions.push(predicate);
    return this;
  }

  take(count: number): this {
    this.limit = count;
    return this;
  }

  skip(count: number): this {
    this.offset = count;
    return this;
  }

  build(): (items: T[]) => T[] {
    return (items: T[]) => {
      let result = items.filter(item =>
        this.conditions.every(c => c(item))
      );
      if (this.offset) result = result.slice(this.offset);
      if (this.limit) result = result.slice(0, this.limit);
      return result;
    };
  }
}

interface Produit {
  id: number;
  nom: string;
  prix: number;
  categorie: string;
}

const query = new QueryBuilder<Produit>()
  .where(p => p.prix < 100)
  .where(p => p.categorie === "Electronique")
  .skip(5)
  .take(10)
  .build();

// Pattern Middleware : pipelines composables
type Middleware<T, U> = (data: T, next: (d: T) => U) => U;

class MiddlewarePipeline<T> {
  private middlewares: Middleware<T, T>[] = [];

  use(mw: Middleware<T, T>): this {
    this.middlewares.push(mw);
    return this;
  }

  execute(data: T): T {
    let index = -1;
    
    const dispatch = (i: number): T => {
      if (i <= index) return data;
      index = i;
      
      if (i === this.middlewares.length) {
        return data;
      }

      const mw = this.middlewares[i];
      return mw(data, (d) => dispatch(i + 1));
    };

    return dispatch(0);
  }
}

interface Request {
  url: string;
  headers: Record<string, string>;
  body?: any;
}

const pipeline = new MiddlewarePipeline<Request>()
  .use((req, next) => {
    console.log(`[LOG] ${req.url}`);
    return next(req);
  })
  .use((req, next) => {
    req.headers["x-timestamp"] = new Date().toISOString();
    return next(req);
  })
  .use((req, next) => {
    if (!req.headers["authorization"]) {
      throw new Error("Missing auth");
    }
    return next(req);
  });

// Pattern Strategy : comportements interchangeables
type ValidationStrategy<T> = (data: T) => boolean;

class Validator<T> {
  private strategies: ValidationStrategy<T>[] = [];

  addStrategy(strategy: ValidationStrategy<T>): this {
    this.strategies.push(strategy);
    return this;
  }

  validate(data: T): { valid: boolean; errors: string[] } {
    const errors: string[] = [];
    for (const strategy of this.strategies) {
      if (!strategy(data)) {
        errors.push(`Strategy failed for ${typeof data}`);
      }
    }
    return { valid: errors.length === 0, errors };
  }
}

interface UserData {
  email: string;
  password: string;
  age: number;
}

const userValidator = new Validator<UserData>()
  .addStrategy(u => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(u.email))
  .addStrategy(u => u.password.length >= 8)
  .addStrategy(u => u.age >= 18);

// Pattern Factory avec Generics
type Constructor<T> = new (...args: any[]) => T;

class Factory {
  private registry = new Map<string, Constructor<any>>();

  register<T>(key: string, ctor: Constructor<T>): void {
    this.registry.set(key, ctor);
  }

  create<T>(key: string, ...args: any[]): T {
    const ctor = this.registry.get(key);
    if (!ctor) throw new Error(`No constructor registered for ${key}`);
    return new ctor(...args);
  }
}

class LoggerConsole {
  log(msg: string) { console.log(msg); }
}

class LoggerFile {
  log(msg: string) { /* write to file */ }
}

const loggerFactory = new Factory();
loggerFactory.register("console", LoggerConsole);
loggerFactory.register("file", LoggerFile);

const logger = loggerFactory.create<LoggerConsole>("console");
Pattern Problème Résolu Avantage
Builder Construction d'objets complexes Fluidité et validation progressive
Middleware Pipelines de traitement Composabilité et séparation des responsabilités
Strategy Comportements interchangeables Flexibilité sans condition
Factory Création polymorphe Découplage et extensibilité
Repository Accès aux données abstrait Testabilité et flexibilité des sources
Observer Réactivité et dépendances Synchronisation automatique des états

💡 Astuce professionnelle
Documentez vos patterns de type avec des exemples concrets. Un pattern brillant mais incompris ralentit votre équipe. Créez des fichiers d'exemple ou des tests unitaires qui montrent les use cases prévus. Le meilleur pattern est celui que votre équipe comprend et maintient.

⚠️ Attention critique
Ne sur-ingénisez pas. Chaque pattern ajoute de la complexité cognitive. Utilisez-les quand vous résolvez un problème réel, pas parce que vous les connaissez. Un simple objet avec une interface claire est souvent préférable à un pattern sophistiqué. Mesurez le ROI : est-ce que ce pattern élimine vraiment de la duplication et augmente la maintenabilité ?