TypeScript types avances - 09 - Overloads et signatures complexes

Comment définir plusieurs signatures pour une meme fonction. Overloads de fonctions, signatures d'implementation, et quand préférer les generics.

09 - Overloads et signatures complexes

Ce que tu vas apprendre

  • Comment déclarer des overloads de fonctions en TypeScript
  • La différence entre les signatures d'overload et la signature d'implementation
  • Quand les overloads sont utiles et quand les generics ou les unions suffisent
  • Les overloads dans les méthodes, les interfaces et les déclarations de types

Prerequisites

Avoir lu les articles sur les generics et les conditional types.


Une fonction, plusieurs contrats

Imagine une fonction createElement qui accepte différentes combinaisons d'arguments :

typescriptcreateElement("div")                    // retourne HTMLDivElement
createElement("a", { href: "/" })       // retourne HTMLAnchorElement
createElement("input", { type: "text" }) // retourne HTMLInputElement

Le type de retour depend de l'argument. Un generic avec des conditional types peut résoudre ca, mais c'est vite illisible. Les overloads offrent une alternative plus claire.

typescriptfunction createElement(tag: "div"): HTMLDivElement
function createElement(tag: "a", attrs?: { href: string }): HTMLAnchorElement
function createElement(tag: "input", attrs?: { type: string }): HTMLInputElement
function createElement(tag: string, attrs?: Record<string, string>): HTMLElement {
  const el = document.createElement(tag)
  if (attrs) Object.assign(el, attrs)
  return el
}

Les trois premières lignes sont les signatures d'overload. La dernière est la signature d'implementation. L'appelant voit les overloads, pas l'implementation.

typescriptconst div = createElement("div")    // type: HTMLDivElement ✅
const link = createElement("a")     // type: HTMLAnchorElement ✅
const input = createElement("input") // type: HTMLInputElement ✅

Regles des overloads

La signature d'implementation doit etre compatible avec toutes les signatures d'overload, mais elle n'est pas visible pour l'appelant :

typescriptfunction format(value: string): string
function format(value: number): string
function format(value: string | number): string {
  return String(value)
}

format("hello") // ✅ utilise le premier overload
format(42)      // ✅ utilise le deuxieme overload
format(true)    // ❌ aucun overload ne correspond

L'ordre des overloads compte. TypeScript essaie chaque signature dans l'ordre et prend la première qui correspond. Mets les signatures les plus spécifiques en premier :

typescript// ❌ Mauvais ordre
function parse(value: string): unknown    // trop generique en premier
function parse(value: string): number     // jamais atteint

// ✅ Bon ordre
function parse(value: `${number}`): number  // specifique en premier
function parse(value: string): unknown      // generique en dernier

Overloads vs unions vs generics

Les overloads ne sont pas toujours la meilleure solution. Comparons :

typescript// Avec overload
function double(value: string): string
function double(value: number): number
function double(value: string | number): string | number {
  if (typeof value === "string") return value + value
  return value * 2
}

// Avec generic — plus concis
function double<T extends string | number>(value: T): T {
  if (typeof value === "string") return (value + value) as T
  return (value as number * 2) as T
}

// Avec conditional type — sans cast
function double<T extends string | number>(
  value: T
): T extends string ? string : number {
  // ...
}

Ma regle : utilise des overloads quand le type de retour n'est pas une simple transformation du type d'entree. Utilise des generics quand le type circule sans transformation. Utilise des conditional types quand la relation est complexe.

Quand les overloads brillent

Les overloads sont supérieurs quand les signatures ont des nombres d'arguments différents ou des types sans rapport :

typescriptfunction query(sql: string): Promise<Row[]>
function query(sql: string, params: unknown[]): Promise<Row[]>
function query(sql: string, params: unknown[], options: QueryOptions): Promise<Row[]>
function query(sql: string, params?: unknown[], options?: QueryOptions): Promise<Row[]> {
  // implementation
}

Ou quand le type de retour depend d'un argument de facon discontinue :

typescriptfunction fetch(url: string): Promise<Response>
function fetch(url: string, options: { json: true }): Promise<unknown>
function fetch(url: string, options?: { json?: boolean }): Promise<Response | unknown> {
  // ...
}

Overloads dans les interfaces

typescriptinterface StringDatabase {
  get(key: string): string | undefined
  get(key: string, defaultValue: string): string
}

L'appelant a un retour différent selon qu'il passe une valeur par défaut ou non :

typescriptfunction useDB(db: StringDatabase) {
  const a = db.get("key")          // string | undefined
  const b = db.get("key", "default") // string (garanti)
}

C'est le pattern qu'utilise la méthode Map.get dans certaines libs utilitaires.

Overloads dans les déclarations de types

typescripttype EventListener = {
  (event: "click", handler: (e: MouseEvent) => void): void
  (event: "keydown", handler: (e: KeyboardEvent) => void): void
  (event: string, handler: (e: Event) => void): void
}

Ce pattern type un callback ou le type de l'event handler depend du nom de l'événement. C'est utilise dans les fichiers .d.ts du DOM.

Exemple concret : API client

Sur paltemps.fr, j'ai un client API avec des overloads selon la méthode HTTP :

typescriptfunction api(method: "GET", path: string): Promise<unknown>
function api(method: "POST", path: string, body: unknown): Promise<unknown>
function api(method: "PUT", path: string, body: unknown): Promise<unknown>
function api(method: "DELETE", path: string): Promise<void>
function api(method: string, path: string, body?: unknown): Promise<unknown> {
  return fetch(path, {
    method,
    body: body ? JSON.stringify(body) : undefined,
    headers: body ? { "Content-Type": "application/json" } : {}
  }).then(r => method === "DELETE" ? undefined : r.json())
}

api("GET", "/api/users")                // ✅ pas de body
api("POST", "/api/users", { name: "a" }) // ✅ body requis
api("DELETE", "/api/users/1")            // ✅ retourne void
api("POST", "/api/users")               // ❌ body manquant

Le compilateur force le bon nombre d'arguments selon la méthode.

Pieges courants

L'implementation trop permissive

typescriptfunction parse(value: string): number
function parse(value: number): string
function parse(value: any): any { // ❌ any masque les erreurs
  // ...
}

Utilise string | number au lieu de any dans l'implementation.

Trop d'overloads

Si tu as plus de 4-5 overloads, c'est un signal que la fonction fait trop de choses. Envisage de la découper en fonctions separees.


Résumé

  • Les overloads definissent plusieurs signatures pour une meme fonction — l'appelant voit les overloads, pas l'implementation
  • La signature d'implementation doit etre compatible avec tous les overloads mais n'est pas visible
  • L'ordre compte : les signatures les plus spécifiques doivent etre en premier
  • Prefere les generics quand le type de retour suit le type d'entree, et les overloads quand la relation est discontinue
  • Les overloads fonctionnent dans les fonctions, interfaces et déclarations de types

Article précédent : 08 - Variance

Article suivant : 10 - Déclaration merging et module augmentation

Sources

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