Cours JavaScript et TypeScript · Module 10: TypeScript en pratique

Module Augmentation et Declaration Merging

14 min de lecture
Dans cette leçon3

Imagine que le parc a acheté un système de caméras clé en main, et que tu veux ajouter à ses rapports un champ dinosaurId. Tu ne peux pas modifier le code du fournisseur, car tes changements disparaîtraient à la prochaine mise à jour. Le module augmentation et le declaration merging sont des fonctionnalités avancées de TypeScript qui permettent d'étendre des modules existants et de fusionner des déclarations sans toucher à l'original. Ces mécanismes sont essentiels quand on travaille avec des bibliothèques externes, qu'on crée des plugins ou qu'on étend des frameworks.

Declaration Merging

Le declaration merging (fusion des déclarations) signifie que le compilateur colle plusieurs déclarations portant le même nom en une seule définition. Cela concerne les interfaces, les espaces de noms (namespace) ainsi que les paires classe et namespace, fonction et namespace, et enum et namespace. En revanche, les alias de types (type) et deux classes du même nom ne fusionnent pas.

Les bases du declaration merging

Le cas le plus fréquent, ce sont deux interfaces du même nom. À côté, tu vois la fusion d'espaces de noms ainsi que celle d'une classe avec un namespace, qui ajoute à la classe des types et des fonctions statiques :

1// Interface merging - le cas le plus fréquent
2interface User {
3  id: number;
4  name: string;
5}
6
7interface User {
8  email: string;  // On ajoute une nouvelle propriété
9  age?: number;   // Et une autre, optionnelle
10}
11
12// TypeScript fusionne automatiquement les deux déclarations
13const user: User = {
14  id: 1,
15  name: "Jean Dupont",
16  email: "jean@example.com"
17  // age est optionnel
18};
19
20// Namespace merging
21namespace MyApp {
22  export interface Config {
23    apiUrl: string;
24  }
25
26  export function initialize(config: Config): void {
27    console.log("Initialisation avec :", config.apiUrl);
28  }
29}
30
31namespace MyApp {
32  export interface Config {
33    timeout?: number;  // On étend l'interface Config
34  }
35
36  export class Logger {
37    log(message: string): void {
38      console.log(`[MyApp]: ${message}`);
39    }
40  }
41}
42
43// On utilise les déclarations fusionnées
44const config: MyApp.Config = {
45  apiUrl: "https://api.example.com",
46  timeout: 5000
47};
48
49const logger = new MyApp.Logger();
50logger.log("Application démarrée");
51
52// Class and namespace merging
53class Album {
54  constructor(public title: string, public artist: string) {}
55}
56
57namespace Album {
58  export interface Track {
59    number: number;
60    title: string;
61    duration: number;
62  }
63
64  export function createFromJSON(json: string): Album {
65    const data = JSON.parse(json);
66    return new Album(data.title, data.artist);
67  }
68}
69
70// On utilise la classe et le namespace ensemble
71const album = new Album("The Wall", "Pink Floyd");
72const track: Album.Track = {
73  number: 1,
74  title: "In The Flesh?",
75  duration: 196
76};
77
78const albumFromJSON = Album.createFromJSON('{"title": "Dark Side", "artist": "Pink Floyd"}');

L'objet user doit avoir les champs des deux déclarations, et Album fonctionne à la fois comme classe (new Album(...)) et comme espace de noms (Album.Track). Si les deux interfaces déclarent le même champ, ses types doivent être identiques, sinon le compilateur signale une erreur.

Fusion de fonctions et de namespaces

Une fonction fusionnée avec un namespace est un objet qu'on peut appeler et qui possède des champs supplémentaires, comme dans de nombreuses bibliothèques JavaScript. Le même principe fonctionne avec un enum :

1// Function and namespace merging - pratique pour créer des callable objects
2function createCounter(initial: number = 0): () => number {
3  let count = initial;
4  return () => ++count;
5}
6
7namespace createCounter {
8  export interface Options {
9    initial?: number;
10    step?: number;
11    max?: number;
12  }
13
14  export function withOptions(options: Options): () => number {
15    let count = options.initial || 0;
16    const step = options.step || 1;
17    const max = options.max || Infinity;
18
19    return () => {
20      if (count + step > max) {
21        return count;
22      }
23      count += step;
24      return count;
25    };
26  }
27
28  export function reset(counter: () => number): void {
29    // Implémentation de la remise à zéro
30    console.log("Counter reset");
31  }
32}
33
34// On utilise la fonction et son namespace
35const counter1 = createCounter(10);
36const counter2 = createCounter.withOptions({ initial: 0, step: 5, max: 100 });
37
38console.log(counter1()); // 11
39console.log(counter2()); // 5
40createCounter.reset(counter1);
41
42// Enum and namespace merging
43enum Status {
44  Idle = "IDLE",
45  Loading = "LOADING",
46  Success = "SUCCESS",
47  Error = "ERROR"
48}
49
50namespace Status {
51  export function isComplete(status: Status): boolean {
52    return status === Status.Success || status === Status.Error;
53  }
54
55  export function isInProgress(status: Status): boolean {
56    return status === Status.Loading;
57  }
58
59  export interface Metadata {
60    timestamp: Date;
61    message?: string;
62  }
63}
64
65// On utilise l'enum avec des fonctions supplémentaires
66const currentStatus = Status.Loading;
67console.log(Status.isInProgress(currentStatus)); // true
68console.log(Status.isComplete(currentStatus));   // false

createCounter(10) renvoie un compteur qui démarre à 10, et createCounter.withOptions est une fonction rattachée au même nom. L'enum Status a reçu des méthodes auxiliaires, et ses valeurs n'ont pas changé.

Merging avec les génériques

Les interfaces génériques fusionnées doivent avoir des paramètres de type identiques, noms compris. La classe implémente alors la somme de toutes les déclarations :

1// Generic interface merging
2interface Collection<T> {
3  items: T[];
4  add(item: T): void;
5}
6
7interface Collection<T> {
8  remove(item: T): boolean;
9  find(predicate: (item: T) => boolean): T | undefined;
10}
11
12class ArrayCollection<T> implements Collection<T> {
13  items: T[] = [];
14
15  add(item: T): void {
16    this.items.push(item);
17  }
18
19  remove(item: T): boolean {
20    const index = this.items.indexOf(item);
21    if (index > -1) {
22      this.items.splice(index, 1);
23      return true;
24    }
25    return false;
26  }
27
28  find(predicate: (item: T) => boolean): T | undefined {
29    return this.items.find(predicate);
30  }
31}
32
33// Generic namespace merging
34namespace DataStructures {
35  export interface Node<T> {
36    value: T;
37    next?: Node<T>;
38  }
39
40  export class LinkedList<T> {
41    head?: Node<T>;
42
43    add(value: T): void {
44      const node: Node<T> = { value };
45      if (!this.head) {
46        this.head = node;
47      } else {
48        let current = this.head;
49        while (current.next) {
50          current = current.next;
51        }
52        current.next = node;
53      }
54    }
55  }
56}
57
58namespace DataStructures {
59  export interface Node<T> {
60    prev?: Node<T>;  // On ajoute un pointeur vers l'élément précédent
61  }
62
63  export class DoublyLinkedList<T> extends LinkedList<T> {
64    // Implémentation d'une liste doublement chaînée
65  }
66}

ArrayCollection doit avoir add, remove et find, car Collection<T> ne forme plus qu'une seule interface. La deuxième partie du namespace DataStructures ajoute au nœud un pointeur prev.

Module Augmentation

Le module augmentation consiste à étendre les types d'un module qui ne t'appartient pas. Tu importes le module d'origine (ce qui fait de ton fichier un module), tu ouvres sa déclaration avec declare module 'nom', tu ajoutes de nouveaux champs ou de nouvelles méthodes à l'interface, et TypeScript fusionne lui-même les déclarations. Pour un fichier local, tu écriras par exemple declare module './dinosaur' { interface Dinosaur { fly(): void } }.

Étendre des modules externes

On ajoute les données de l'utilisateur à la requête Express, et des méthodes auxiliaires à la réponse. On étend express-serve-static-core, car c'est de là que viennent les types de req et res dans les handlers :

1// Extension des types d'Express (req et res dans les handlers viennent d'express-serve-static-core)
2declare module "express-serve-static-core" {
3  interface Request {
4    user?: {
5      id: string;
6      email: string;
7      roles: string[];
8    };
9    requestId?: string;
10  }
11
12  interface Response {
13    sendSuccess(data: any): void;
14    sendError(error: Error, statusCode?: number): void;
15  }
16}
17
18// Implémentation des extensions
19import express from "express";
20
21const app = express();
22
23// Middleware qui ajoute user et requestId
24app.use((req, res, next) => {
25  req.requestId = crypto.randomUUID();
26  // Authentification et ajout de l'utilisateur
27  req.user = {
28    id: "123",
29    email: "user@example.com",
30    roles: ["user", "admin"]
31  };
32  next();
33});
34
35// Extension de Response avec de nouvelles méthodes
36app.use((req, res, next) => {
37  res.sendSuccess = function(data: any) {
38    this.json({
39      success: true,
40      data,
41      requestId: req.requestId
42    });
43  };
44
45  res.sendError = function(error: Error, statusCode: number = 500) {
46    this.status(statusCode).json({
47      success: false,
48      error: error.message,
49      requestId: req.requestId
50    });
51  };
52
53  next();
54});
55
56// On utilise les types étendus
57app.get("/profile", (req, res) => {
58  if (!req.user) {
59    return res.sendError(new Error("Unauthorized"), 401);
60  }
61
62  res.sendSuccess({
63    user: req.user,
64    requestId: req.requestId
65  });
66});

L'augmentation ne modifie que les types, donc c'est toujours un middleware qui ajoute l'implémentation de sendSuccess et sendError avant que le handler ne s'en serve. La règle générale : étends le module qui déclare réellement le type, et non celui qui se contente de le réexporter.

Étendre les types globaux

Le bloc declare global étend les types globaux, comme Window, Array ou String. Il ne fonctionne que dans un fichier qui est un module, donc si ton fichier n'a pas d'imports, ajoute export {} à la fin :

1// Extension des objets globaux
2declare global {
3  interface Window {
4    myApp: {
5      version: string;
6      config: {
7        apiUrl: string;
8        debugMode: boolean;
9      };
10      utils: {
11        formatDate(date: Date): string;
12        parseJSON<T>(json: string): T | null;
13      };
14    };
15  }
16
17  interface Array<T> {
18    groupBy<K extends keyof T>(key: K): Record<string, T[]>;
19    chunk(size: number): T[][];
20  }
21
22  interface String {
23    capitalize(): string;
24    toSlug(): string;
25  }
26}
27
28// Implémentation des extensions d'Array
29Array.prototype.groupBy = function<T, K extends keyof T>(this: T[], key: K): Record<string, T[]> {
30  return this.reduce((acc, item) => {
31    const groupKey = String(item[key]);
32    if (!acc[groupKey]) {
33      acc[groupKey] = [];
34    }
35    acc[groupKey].push(item);
36    return acc;
37  }, {} as Record<string, T[]>);
38};
39
40Array.prototype.chunk = function<T>(this: T[], size: number): T[][] {
41  const chunks: T[][] = [];
42  for (let i = 0; i < this.length; i += size) {
43    chunks.push(this.slice(i, i + size));
44  }
45  return chunks;
46};
47
48// Implémentation des extensions de String
49String.prototype.capitalize = function(this: string): string {
50  return this.charAt(0).toUpperCase() + this.slice(1).toLowerCase();
51};
52
53String.prototype.toSlug = function(this: string): string {
54  return this
55    .toLowerCase()
56    .trim()
57    .replace(/[^\w\s-]/g, '')
58    .replace(/[\s_-]+/g, '-')
59    .replace(/^-+|-+$/g, '');
60};
61
62// On utilise les extensions
63const users = [
64  { name: "Jan", age: 30, department: "IT" },
65  { name: "Anna", age: 25, department: "HR" },
66  { name: "Piotr", age: 35, department: "IT" }
67];
68
69const grouped = users.groupBy("department");
70console.log(grouped); // { IT: [...], HR: [...] }
71
72const chunks = [1, 2, 3, 4, 5, 6, 7, 8].chunk(3);
73console.log(chunks); // [[1, 2, 3], [4, 5, 6], [7, 8]]
74
75const title = "hello world from typescript".capitalize();
76console.log(title); // "Hello world from typescript"
77
78const slug = "Hello World! From TypeScript".toSlug();
79console.log(slug); // "hello-world-from-typescript"
80
81// Extensions de Window
82window.myApp = {
83  version: "1.0.0",
84  config: {
85    apiUrl: "https://api.example.com",
86    debugMode: true
87  },
88  utils: {
89    formatDate(date: Date): string {
90      return date.toLocaleDateString("fr-FR");
91    },
92    parseJSON<T>(json: string): T | null {
93      try {
94        return JSON.parse(json) as T;
95      } catch {
96        return null;
97      }
98    }
99  }
100};

Techniquement, cela fonctionne, mais je ne te recommande pas d'étendre les prototypes intégrés. Ce sont justement ce genre de rustines (la bibliothèque Sugar ajoutait son propre groupBy aux tableaux) qui ont empêché le standard d'utiliser le nom Array.prototype.groupBy, si bien qu'ES2024 a introduit la méthode statique Object.groupBy. Il est plus sûr d'écrire des fonctions auxiliaires ordinaires ou d'étendre tes propres objets, comme window.myApp.

Un système de plugins avec le module augmentation

Un système de plugins permet à différentes parties de l'application d'ajouter leurs propres types de contexte et de hooks à un module commun :

1// Système de plugins de base
2interface PluginSystem {
3  plugins: Map<string, Plugin>;
4  register(plugin: Plugin): void;
5  execute(hookName: string, context: any): void;
6}
7
8interface Plugin {
9  name: string;
10  version: string;
11  hooks?: {
12    [hookName: string]: (context: any) => void | Promise<void>;
13  };
14}
15
16class PluginManager implements PluginSystem {
17  plugins = new Map<string, Plugin>();
18
19  register(plugin: Plugin): void {
20    this.plugins.set(plugin.name, plugin);
21    console.log(`Plugin ${plugin.name} v${plugin.version} enregistré`);
22  }
23
24  async execute(hookName: string, context: any): Promise<void> {
25    for (const [name, plugin] of this.plugins) {
26      if (plugin.hooks && plugin.hooks[hookName]) {
27        await plugin.hooks[hookName](context);
28      }
29    }
30  }
31}
32
33// Extension du système de plugins par différents modules
34declare module "./plugin-system" {
35  interface PluginContext {
36    user?: {
37      id: string;
38      name: string;
39    };
40    request?: {
41      url: string;
42      method: string;
43    };
44  }
45
46  interface PluginHooks {
47    beforeRequest?: (context: PluginContext) => void;
48    afterRequest?: (context: PluginContext & { response: any }) => void;
49    onError?: (context: PluginContext & { error: Error }) => void;
50  }
51}
52
53// Plugin d'authentification
54const authPlugin: Plugin = {
55  name: "auth",
56  version: "1.0.0",
57  hooks: {
58    beforeRequest(context) {
59      console.log("Vérification de l'authentification...");
60      if (!context.user) {
61        throw new Error("Non authentifié");
62      }
63    }
64  }
65};
66
67// Plugin de journalisation
68const loggingPlugin: Plugin = {
69  name: "logging",
70  version: "1.0.0",
71  hooks: {
72    beforeRequest(context) {
73      console.log(`[${new Date().toISOString()}] ${context.request?.method} ${context.request?.url}`);
74    },
75    afterRequest(context) {
76      console.log(`Réponse pour ${context.request?.url} :`, context.response);
77    },
78    onError(context) {
79      console.error(`Erreur pour ${context.request?.url} :`, context.error);
80    }
81  }
82};
83
84// Utilisation du système de plugins
85const pluginManager = new PluginManager();
86pluginManager.register(authPlugin);
87pluginManager.register(loggingPlugin);
88
89// Simulation d'une requête
90const context = {
91  user: { id: "123", name: "Jan" },
92  request: { url: "/api/data", method: "GET" }
93};
94
95pluginManager.execute("beforeRequest", context);

L'augmentation declare module './plugin-system' ne fonctionne que si ce fichier existe, et le code lui-même doit se trouver dans un module. Dans un fichier sans imports, l'interface Plugin fusionnerait avec l'interface globale Plugin des définitions du DOM, qui décrit les plugins du navigateur. C'est le declaration merging sous sa forme la moins souhaitable.

Applications pratiques

Étendre des bibliothèques d'UI

Material UI encourage ouvertement l'augmentation : tu ajoutes tes propres champs de thème et variantes de typographie aux interfaces Theme, ThemeOptions et TypographyVariants :

1// Extension de Material-UI
2declare module "@mui/material/styles" {
3  interface Theme {
4    custom: {
5      borderRadius: {
6        small: number;
7        medium: number;
8        large: number;
9      };
10      animations: {
11        fast: string;
12        normal: string;
13        slow: string;
14      };
15    };
16  }
17
18  interface ThemeOptions {
19    custom?: {
20      borderRadius?: {
21        small?: number;
22        medium?: number;
23        large?: number;
24      };
25      animations?: {
26        fast?: string;
27        normal?: string;
28        slow?: string;
29      };
30    };
31  }
32
33  interface TypographyVariants {
34    label: React.CSSProperties;
35    caption2: React.CSSProperties;
36  }
37
38  interface TypographyVariantsOptions {
39    label?: React.CSSProperties;
40    caption2?: React.CSSProperties;
41  }
42}
43
44declare module "@mui/material/Typography" {
45  interface TypographyPropsVariantOverrides {
46    label: true;
47    caption2: true;
48  }
49}
50
51// Création d'un thème étendu
52import { createTheme } from "@mui/material/styles";
53
54const theme = createTheme({
55  custom: {
56    borderRadius: {
57      small: 4,
58      medium: 8,
59      large: 16
60    },
61    animations: {
62      fast: "150ms",
63      normal: "300ms",
64      slow: "500ms"
65    }
66  },
67  typography: {
68    label: {
69      fontSize: "0.75rem",
70      fontWeight: 600,
71      textTransform: "uppercase"
72    },
73    caption2: {
74      fontSize: "0.625rem",
75      fontWeight: 400
76    }
77  }
78});
79
80// Utilisation des types étendus
81import { Typography, Box } from "@mui/material";
82
83function CustomComponent() {
84  return (
85    <Box
86      sx={{
87        borderRadius: (theme) => theme.custom.borderRadius.medium,
88        transition: (theme) => `all ${theme.custom.animations.normal} ease`
89      }}
90    >
91      <Typography variant="label">Custom Label</Typography>
92      <Typography variant="caption2">Tiny caption text</Typography>
93    </Box>
94  );
95}

Theme décrit le thème final, et ThemeOptions les données passées à createTheme, c'est pourquoi les champs y sont optionnels. Le code contient du JSX, donc dans un projet, tu le places dans un fichier .tsx.

Étendre des frameworks

De la même façon, tu étendras les types de Next.js pour les API routes du Pages Router, en ajoutant la session et des méthodes auxiliaires :

1// Extension de Next.js
2declare module "next" {
3  export interface NextApiRequest {
4    session?: {
5      user: {
6        id: string;
7        email: string;
8        permissions: string[];
9      };
10      expiresAt: Date;
11    };
12
13    // Méthodes auxiliaires
14    validateBody<T>(schema: any): T;
15    requireAuth(): void;
16    hasPermission(permission: string): boolean;
17  }
18
19  export interface NextApiResponse {
20    success<T>(data: T, statusCode?: number): void;
21    error(message: string, statusCode?: number): void;
22    paginated<T>(items: T[], total: number, page: number, pageSize: number): void;
23  }
24}
25
26// Middleware qui étend Next.js
27import { NextApiRequest, NextApiResponse } from "next";
28
29export function withExtensions(
30  handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void>
31) {
32  return async (req: NextApiRequest, res: NextApiResponse) => {
33    // Ajouter des méthodes à la requête
34    req.validateBody = function<T>(schema: any): T {
35      // Implémentation de la validation
36      return req.body as T;
37    };
38
39    req.requireAuth = function() {
40      if (!this.session) {
41        throw new Error("Authentication required");
42      }
43    };
44
45    req.hasPermission = function(permission: string): boolean {
46      return this.session?.user.permissions.includes(permission) || false;
47    };
48
49    // Ajouter des méthodes à la réponse
50    res.success = function<T>(data: T, statusCode = 200) {
51      this.status(statusCode).json({
52        success: true,
53        data
54      });
55    };
56
57    res.error = function(message: string, statusCode = 500) {
58      this.status(statusCode).json({
59        success: false,
60        error: message
61      });
62    };
63
64    res.paginated = function<T>(items: T[], total: number, page: number, pageSize: number) {
65      this.json({
66        success: true,
67        data: items,
68        pagination: {
69          total,
70          page,
71          pageSize,
72          totalPages: Math.ceil(total / pageSize)
73        }
74      });
75    };
76
77    // Appeler le handler
78    try {
79      await handler(req, res);
80    } catch (error) {
81      res.error(error instanceof Error ? error.message : "Internal server error");
82    }
83  };
84}
85
86// Utilisation dans une API route
87export default withExtensions(async (req, res) => {
88  req.requireAuth();
89
90  if (!req.hasPermission("read:users")) {
91    return res.error("Insufficient permissions", 403);
92  }
93
94  const data = req.validateBody<{ search?: string }>(/* schema */);
95
96  // Logique métier
97  const users = []; // Récupérer les utilisateurs
98  const total = 100;
99
100  res.paginated(users, total, 1, 20);
101});

Dans l'App Router, les routes d'API sont gérées par des Route Handlers basés sur les objets standard Request et Response, donc là-bas, au lieu d'une augmentation, tu écris généralement des fonctions auxiliaires. Pour les types réexportés, vérifie de quel fichier ils proviennent, comme pour Express.

Le module augmentation et le declaration merging sont des outils puissants pour étendre du code existant avec souplesse. La clé est de comprendre quand utiliser chaque technique et comment elles peuvent coopérer dans des systèmes extensibles. Mon conseil : range tes augmentations dans un dossier à part, par exemple types/, pour que toute l'équipe sache où chercher les ajouts aux types des autres. Dans l'éditeur ci-dessous, tu t'exerceras à la fusion des déclarations, et dans la leçon suivante, tu découvriras le Test-Driven Development.

Retiens : une augmentation, c'est un autocollant annoté posé sur la carte du parc d'un autre, qui n'efface rien et ne fait qu'ajouter de nouvelles informations.

Code de cette leçon : index.ts
1// Exemples de module augmentation et declaration merging
2
3// 1. Interface merging - fusion de déclarations
4interface User {
5  id: number;
6  name: string;
7}
8
9interface User {
10  email: string;    // Ajoutons de nouvelles propriétés
11  age?: number;     // Propriété optionnelle
12}
13
14// TypeScript fusionne automatiquement les deux déclarations
15const user: User = {
16  id: 1,
17  name: "Jan Kowalski",
18  email: "jan@example.com",
19  age: 30
20};
21
22// 2. Namespace merging
23namespace AppConfig {
24  export interface Settings {
25    apiUrl: string;
26    timeout: number;
27  }
28
29  export function getDefaultSettings(): Settings {
30    return {
31      apiUrl: "https://api.example.com",
32      timeout: 5000
33    };
34  }
35}
36
37namespace AppConfig {
38  export interface Settings {
39    debugMode?: boolean;  // On étend l'interface
40  }
41
42  export class ConfigManager {
43    private settings: Settings;
44
45    constructor() {
46      this.settings = getDefaultSettings();
47    }
48
49    updateSettings(updates: Partial<Settings>): void {
50      this.settings = { ...this.settings, ...updates };
51    }
52
53    getSettings(): Settings {
54      return this.settings;
55    }
56  }
57}
58
59// On utilise les déclarations fusionnées
60const configManager = new AppConfig.ConfigManager();
61configManager.updateSettings({ debugMode: true });
62console.log("Réglages :", configManager.getSettings());
63
64// 3. Function et namespace merging
65function createLogger(prefix: string) {
66  return {
67    log(message: string) {
68      console.log(`[${prefix}] ${message}`);
69    }
70  };
71}
72
73namespace createLogger {
74  export interface Options {
75    timestamp?: boolean;
76    color?: string;
77  }
78
79  export function withOptions(prefix: string, options: Options) {
80    return {
81      log(message: string) {
82        const timestamp = options.timestamp ? new Date().toISOString() : '';
83        console.log(`${timestamp} [${prefix}] ${message}`);
84      }
85    };
86  }
87
88  export const DEFAULT_OPTIONS: Options = {
89    timestamp: true,
90    color: 'default'
91  };
92}
93
94// On utilise la fonction et le namespace
95const logger1 = createLogger("APP");
96const logger2 = createLogger.withOptions("DEBUG", { timestamp: true });
97
98logger1.log("Message simple");
99logger2.log("Message avec horodatage");
100
101// 4. Étendre les types globaux
102declare global {
103  interface Array<T> {
104    first(): T | undefined;
105    last(): T | undefined;
106    groupBy<K extends keyof T>(key: K): Record<string, T[]>;
107  }
108
109  interface String {
110    capitalize(): string;
111    toKebabCase(): string;
112  }
113}
114
115// Implémentation des extensions
116Array.prototype.first = function<T>(this: T[]): T | undefined {
117  return this[0];
118};
119
120Array.prototype.last = function<T>(this: T[]): T | undefined {
121  return this[this.length - 1];
122};
123
124Array.prototype.groupBy = function<T, K extends keyof T>(
125  this: T[],
126  key: K
127): Record<string, T[]> {
128  return this.reduce((groups, item) => {
129    const groupKey = String(item[key]);
130    groups[groupKey] = groups[groupKey] || [];
131    groups[groupKey].push(item);
132    return groups;
133  }, {} as Record<string, T[]>);
134};
135
136String.prototype.capitalize = function(this: string): string {
137  return this.charAt(0).toUpperCase() + this.slice(1);
138};
139
140String.prototype.toKebabCase = function(this: string): string {
141  return this
142    .replace(/([A-Z])/g, '-$1')
143    .toLowerCase()
144    .replace(/^-/, '');
145};
146
147// Test des extensions
148const numbers = [1, 2, 3, 4, 5];
149console.log("Premier élément :", numbers.first());  // 1
150console.log("Dernier élément :", numbers.last());    // 5
151
152const products = [
153  { name: "Laptop", category: "Electronics", price: 1000 },
154  { name: "Mouse", category: "Electronics", price: 50 },
155  { name: "Desk", category: "Furniture", price: 300 }
156];
157
158const grouped = products.groupBy("category");
159console.log("Produits groupés :", grouped);
160
161const text = "hello world";
162console.log("Capitalize :", text.capitalize());  // "Hello world"
163
164const camelCase = "myVariableName";
165console.log("Kebab case :", camelCase.toKebabCase());  // "my-variable-name"
166
167// 5. Class et namespace merging
168class EventEmitter {
169  private events: Map<string, Function[]> = new Map();
170
171  on(event: string, handler: Function): void {
172    if (!this.events.has(event)) {
173      this.events.set(event, []);
174    }
175    this.events.get(event)!.push(handler);
176  }
177
178  emit(event: string, ...args: any[]): void {
179    const handlers = this.events.get(event);
180    if (handlers) {
181      handlers.forEach(handler => handler(...args));
182    }
183  }
184}
185
186namespace EventEmitter {
187  export interface EventMap {
188    connect: { userId: string; timestamp: Date };
189    disconnect: { userId: string; reason: string };
190    message: { userId: string; text: string };
191  }
192
193  export type EventHandler<K extends keyof EventMap> =
194    (data: EventMap[K]) => void;
195
196  export function createTypedEmitter() {
197    const emitter = new EventEmitter();
198
199    return {
200      on<K extends keyof EventMap>(event: K, handler: EventHandler<K>): void {
201        emitter.on(event, handler);
202      },
203
204      emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
205        emitter.emit(event, data);
206      }
207    };
208  }
209}
210
211// On utilise l'émetteur d'événements typé
212const typedEmitter = EventEmitter.createTypedEmitter();
213
214typedEmitter.on('connect', (data) => {
215  console.log(`L'utilisateur ${data.userId} s'est connecté à ${data.timestamp}`);
216});
217
218typedEmitter.on('message', (data) => {
219  console.log(`${data.userId} : ${data.text}`);
220});
221
222typedEmitter.emit('connect', {
223  userId: 'user123',
224  timestamp: new Date()
225});
226
227typedEmitter.emit('message', {
228  userId: 'user123',
229  text: 'Bienvenue à tous !'
230});
231
232// 6. Module augmentation (simulation)
233// Dans une vraie application, cela serait dans un fichier séparé
234interface MyLibrary {
235  version: string;
236  utils: {
237    formatDate(date: Date): string;
238  };
239}
240
241// On étend la bibliothèque
242interface MyLibrary {
243  plugins: Plugin[];
244  registerPlugin(plugin: Plugin): void;
245}
246
247interface Plugin {
248  name: string;
249  init(): void;
250}
251
252// Implémentation de la bibliothèque étendue
253const myLibrary: MyLibrary = {
254  version: "1.0.0",
255  utils: {
256    formatDate(date: Date): string {
257      return date.toLocaleDateString('fr-FR');
258    }
259  },
260  plugins: [],
261  registerPlugin(plugin: Plugin): void {
262    this.plugins.push(plugin);
263    plugin.init();
264    console.log(`Plugin ${plugin.name} enregistré`);
265  }
266};
267
268// Test
269const datePlugin: Plugin = {
270  name: "DateFormatter",
271  init() {
272    console.log("Plugin DateFormatter initialisé");
273  }
274};
275
276myLibrary.registerPlugin(datePlugin);
277console.log("Version de la bibliothèque :", myLibrary.version);
278console.log("Date formatée :", myLibrary.utils.formatDate(new Date()));
279
280// Export pour éviter les erreurs de déclarations globales
281export {};

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quelles déclarations peuvent être fusionnées automatiquement en TypeScript ?

Tâches pratiques dans le jeu

  • Clique dans l'ordre

    Ordonne les étapes de création des ambient modules.

  • Éditeur de code

    Étends des types existants grâce au declaration merging.

  • Clique dans l'ordre

    Ordonne les éléments d'une déclaration de module augmentation :

  • Tri vertical

    Ordonne les étapes d'extension d'un module via le module augmentation :

Articles utiles