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,outde 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 :
Dog→Animal) - La contravariance inverse le sens (entree :
Animal→Dog) - L'invariance interdit les deux sens (entree + sortie)
strictFunctionTypesrend les paramètres de fonctions contravariants (safe)- Les annotations
in/outdocumentent 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