TypeScript types avances - 08 - Variance : covariance et contravariance

Pourquoi Array n'est pas toujours assignable a Array. Comprendre la covariance, la contravariance, et l'invariance en TypeScript.

08 - Variance : covariance et contravariance

Ce que tu vas apprendre

  • Ce que la variance signifie et pourquoi elle existe
  • La covariance (position de sortie) et la contravariance (position d'entree)
  • Pourquoi TypeScript est bivariant sur les méthodes et les consequences
  • Les annotations in, out de TypeScript 4.7

Prerequisites

Avoir lu les articles sur les generics et les unions et intersections.


Le bug que le compilateur laisse passer

typescriptclass Animal {
  name: string = ""
}

class Dog extends Animal {
  breed: string = ""
}

const dogs: Dog[] = [{ name: "Rex", breed: "Berger" }]
const animals: Animal[] = dogs // ✅ compile

animals.push(new Animal()) // on ajoute un Animal generique

console.log(dogs[1].breed) // 💥 runtime error — dogs[1] est un Animal, pas un Dog

TypeScript permet d'assigner Dog[] a Animal[]. C'est de la covariance. Mais ca ouvre la porte a un bug : tu peux ajouter un Animal dans un tableau qui ne devrait contenir que des Dog.

C'est un compromis delibere de TypeScript. Interdire cette assignation rendrait le langage trop restrictif pour l'usage quotidien. Mais il faut comprendre le mecanisme pour éviter les pièges.

Covariance : le sens "naturel"

Un type est covariant quand la relation de sous-typage se conserve dans le meme sens.

Dog extends Animal (Dog est un sous-type d'Animal). Si un type Container<T> est covariant en T, alors Container<Dog> est assignable a Container<Animal>.

Ca fonctionne pour les positions de sortie (retours de fonctions, propriétés en lecture) :

typescripttype Producer<T> = () => T

const produceDog: Producer<Dog> = () => new Dog()
const produceAnimal: Producer<Animal> = produceDog // ✅ covariant

Si une fonction promet de retourner un Animal, une fonction qui retourne un Dog est acceptable. Un Dog est un Animal.

Contravariance : le sens "inverse"

Un type est contravariant quand la relation s'inverse.

Dog extends Animal. Mais pour les paramètres de fonctions (positions d'entree), la relation s'inverse : Consumer<Animal> est assignable a Consumer<Dog>.

typescripttype Consumer<T> = (value: T) => void

const consumeAnimal: Consumer<Animal> = (a) => console.log(a.name)
const consumeDog: Consumer<Dog> = consumeAnimal // ✅ contravariant

Une fonction qui sait gerer n'importe quel Animal peut évidemment gerer un Dog. Le sens est inverse.

Par contre, l'inverse est dangereux :

typescriptconst consumeDog: Consumer<Dog> = (d) => console.log(d.breed)
const consumeAnimal: Consumer<Animal> = consumeDog // ❌ dangereux
// consumeAnimal pourrait recevoir un Cat, mais consumeDog attend un Dog

Invariance : ni l'un ni l'autre

Un type est invariant quand aucune assignation dans aucun sens n'est permise. C'est le cas quand un paramètre de type apparaît en position d'entree ET de sortie.

typescriptinterface MutableBox<T> {
  get(): T     // position de sortie → covariant
  set(v: T): void // position d'entree → contravariant
}
// T est en position covariante ET contravariante → invariant

MutableBox<Dog> n'est pas assignable a MutableBox<Animal> et vice versa. C'est le comportement le plus strict mais le plus sur.

Le mode strict : strictFunctionTypes

Avant TypeScript 2.6, les paramètres de fonctions etaient bivariants : l'assignation fonctionnait dans les deux sens, ce qui etait unsafe. Depuis strictFunctionTypes: true (inclus dans strict: true), les paramètres sont contravariants.

typescript// Avec strictFunctionTypes: true
type Handler = (event: MouseEvent) => void
const handler: Handler = (event: Event) => {} // ✅ Event est plus large
const handler2: Handler = (event: PointerEvent) => {} // ❌ PointerEvent est plus etroit

Exception : les méthodes declarees avec la syntaxe de méthode (pas de propriété fonction) restent bivariantes pour des raisons de retrocompatibilite :

typescriptinterface EventTarget {
  // Syntaxe methode — bivariant (moins strict)
  addEventListener(type: string, handler: (event: Event) => void): void
}

interface StrictEventTarget {
  // Syntaxe propriete — contravariant (plus strict)
  addEventListener: (type: string, handler: (event: Event) => void) => void
}

C'est une subtilite a connaître. Si tu veux un maximum de sécurité, utilise la syntaxe propriété pour les callbacks dans tes interfaces.

Les annotations in et out (TypeScript 4.7)

Depuis TypeScript 4.7, tu peux annoter la variance d'un paramètre de type :

typescript// out = covariant (position de sortie)
interface Producer<out T> {
  produce(): T
}

// in = contravariant (position d'entree)
interface Consumer<in T> {
  consume(value: T): void
}

// in out = invariant
interface Box<in out T> {
  get(): T
  set(value: T): void
}

Les annotations ne changent pas le comportement. Elles le documentent et permettent au compilateur de vérifier que tu n'utilises pas T dans la mauvaise position :

typescriptinterface Producer<out T> {
  produce(): T
  consume(value: T): void // ❌ erreur : T en position 'in' mais annote 'out'
}

En pratique, la plupart des devs n'ont pas besoin d'annoter la variance manuellement. TypeScript l'infere correctement. Les annotations sont utiles pour les auteurs de libs qui veulent documenter et vérifier l'intent.

Impact en pratique

React et les callbacks

typescriptinterface Props {
  onClick: (event: MouseEvent) => void
}

// Tu peux passer un handler qui accepte un type plus large
function App() {
  const handleClick = (event: Event) => console.log(event.type)
  return <Button onClick={handleClick} /> // ✅ contravariance
}

Collections immutables vs mutables

Sur paltemps.fr, les listes en lecture seule sont covariantes (safe), les listes mutables sont problématiques :

typescript// ✅ Safe — readonly est covariant
const readonlyDogs: readonly Dog[] = [new Dog()]
const readonlyAnimals: readonly Animal[] = readonlyDogs

// ⚠️ Unsafe — mutable array est "covariant" en TS (compromis)
const mutableDogs: Dog[] = [new Dog()]
const mutableAnimals: Animal[] = mutableDogs // compile mais unsafe

C'est une raison de plus d'utiliser readonly sur les tableaux quand tu ne mutes pas : ca rend la covariance safe.


Résumé

  • La covariance preserve le sens du sous-typage (sortie : DogAnimal)
  • La contravariance inverse le sens (entree : AnimalDog)
  • L'invariance interdit les deux sens (entree + sortie)
  • strictFunctionTypes rend les paramètres de fonctions contravariants (safe)
  • Les annotations in/out documentent la variance et permettent au compilateur de la vérifier
  • Les tableaux readonly sont covariants de facon safe, les tableaux mutables sont un compromis

Article précédent : 07 - Types recursifs

Article suivant : 09 - Overloads et signatures complexes

Sources

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