TypeScript types avances - 10 - Déclaration merging et module augmentation

Comment etendre les types de libs tierces sans les modifier. Déclaration merging, module augmentation, et global augmentation.

10 - Déclaration merging et module augmentation

Ce que tu vas apprendre

  • Comment TypeScript fusionne automatiquement les déclarations du meme nom
  • Comment etendre les types d'une lib tierce avec module augmentation
  • Comment ajouter des types globaux (Window, process.env)
  • Les cas d'usage concrets : Express, Prisma, env variables

Prerequisites

Avoir lu les articles sur les interfaces vs types et les generics.


Le problème des types qu'on ne contrôle pas

Quand tu utilises Express, le type de req est Request. Mais apres ton middleware d'authentification, req a une propriété user que les types d'Express ne connaissent pas :

typescriptapp.use(authMiddleware)

app.get("/profile", (req, res) => {
  console.log(req.user.id) // ❌ Property 'user' does not exist on type 'Request'
})

Tu ne peux pas modifier les types d'Express dans node_modules. Et any n'est pas une option. La solution : déclaration merging.

Déclaration merging

TypeScript fusionne automatiquement deux déclarations qui ont le meme nom dans le meme scope. Ca fonctionne avec les interfaces (pas les types) :

typescriptinterface User {
  name: string
}

interface User {
  email: string
}

// Resultat : User a les deux proprietes
const user: User = {
  name: "Nicolas",
  email: "n@n.dev"
}

Les deux déclarations sont fusionnees. C'est spécifique aux interfaces — les type aliases ne peuvent pas etre fusionnes.

Ca fonctionne aussi avec les namespaces, les enums, et entre interfaces et classes. Mais le cas d'usage le plus courant, c'est l'extension de types de libs tierces.

Module augmentation

Pour etendre les types d'un module npm, tu declares un module avec le meme nom :

typescript// types/express.d.ts
import { User } from "../models/user"

declare module "express-serve-static-core" {
  interface Request {
    user: User
  }
}

Maintenant req.user est type dans tous tes handlers Express :

typescriptapp.get("/profile", (req, res) => {
  console.log(req.user.id) // ✅ type: User
})

Le declare module "express-serve-static-core" dit a TypeScript : "ajoute ces déclarations au module existant". Les interfaces du meme nom sont fusionnees.

Trouver le bon module a augmenter

Le piège classique : augmenter le mauvais module. Pour Express, le type Request est dans express-serve-static-core, pas dans express. Comment le savoir ?

  1. Ouvre node_modules/@types/express/index.d.ts
  2. Regarde d'ou Request est importe
  3. Augmente ce module-la

Ou utilise ton IDE : fais Ctrl+Click sur Request et regarde dans quel module il est déclaré.

Global augmentation

Pour ajouter des types a l'objet global (window, globalThis, process) :

typescript// types/global.d.ts
declare global {
  interface Window {
    dataLayer: Record<string, unknown>[]
    gtag: (...args: unknown[]) => void
  }
}

export {} // necessaire pour que le fichier soit un module

Le declare global {} dit a TypeScript : "ajoute ces déclarations au scope global". Le export {} est obligatoire pour que TypeScript traite le fichier comme un module (sinon declare global ne fonctionne pas).

typescriptwindow.dataLayer.push({ event: "page_view" }) // ✅ type
window.gtag("event", "purchase", { value: 99 }) // ✅ type

Variables d'environnement typees

Un pattern que j'utilise sur paltemps.fr :

typescript// types/env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: "development" | "production" | "test"
      DATABASE_URL: string
      ADMIN_PASSWORD: string
      ANTHROPIC_API_KEY: string
      PORT?: string
    }
  }
}

export {}

Maintenant process.env.DATABASE_URL est type string (pas string | undefined) et process.env.NODE_ENV n'accepte que les trois valeurs définies. L'autocompletion propose les variables disponibles.

Augmenter des libs courantes

Prisma : ajouter des méthodes custom

typescript// types/prisma.d.ts
import { PrismaClient } from "@prisma/client"

declare module "@prisma/client" {
  interface PrismaClient {
    $softDelete<T>(model: string, id: string): Promise<T>
  }
}

Elysia / Hono : context custom

typescriptdeclare module "elysia" {
  interface Context {
    user: AuthUser
    requestId: string
  }
}

Vue : propriétés globales

typescriptdeclare module "vue" {
  interface ComponentCustomProperties {
    $translate: (key: string) => string
  }
}

Interface merging vs type : pourquoi ca compte

C'est une des raisons pour lesquelles les interfaces et les types ne sont pas interchangeables. Les types ne se fusionnent pas :

typescript// ❌ Erreur : Duplicate identifier
type Config = { port: number }
type Config = { host: string }

// ✅ Fonctionne : merge
interface Config { port: number }
interface Config { host: string }

Si tu publies une lib et que tu veux que tes utilisateurs puissent etendre tes types, utilise des interfaces pour les types extensibles (options, contexte, config) et des types pour les types fermes (unions, intersections, utilitaires).

Fichiers .d.ts

Les déclarations d'augmentation vont généralement dans des fichiers .d.ts dans un dossier types/ a la racine du projet. Le tsconfig doit inclure ce dossier :

json{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./types"]
  },
  "include": ["src/**/*", "types/**/*"]
}

Ou plus simplement, assure-toi que les fichiers .d.ts sont dans le scope include du tsconfig.

Pieges

Le fichier doit etre un module

Si ton fichier .d.ts n'a pas d'import ou d'export, TypeScript le traite comme un script global. Le declare module ne fera pas ce que tu attends. Ajoute export {} en bas du fichier.

Augmentation vs remplacement

L'augmentation ajoute des propriétés. Elle ne remplace pas les existantes. Si tu veux changer le type d'une propriété existante, c'est plus complique et généralement déconseillé.

Conflits entre augmentations

Si deux fichiers augmentent la meme interface avec des propriétés du meme nom mais de types différents, tu auras une erreur de compilation. Verifie que tes augmentations ne se contredisent pas.


Résumé

  • TypeScript fusionne automatiquement les interfaces du meme nom (déclaration merging)
  • declare module "nom" etend les types d'un module npm sans modifier ses fichiers
  • declare global {} ajoute des types au scope global (Window, process.env)
  • Les interfaces sont extensibles, les types ne le sont pas — utilise des interfaces pour les types publics extensibles
  • Les fichiers d'augmentation doivent etre des modules (ajouter export {} si pas d'import)

Article précédent : 09 - Overloads

Article suivant : 11 - Symbols, unique symbol et opaque types

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.