TypeScript Variance in Practice
Variance in practice is about the corners where the checker permits an unsafe substitution. Method parameters are checked loosely in both directions, while function-typed properties are checked strictly.
A method parameter betrays at runtime
The greet member is a method, so a dog greeter stands where an animal greeter is expected. The call passes a plain animal, and the breed read finds nothing.
interface Animal { name: string }
interface Dog extends Animal { breed: string }
const dogGreeter = {
greet(dog: Dog) {
console.log('Breed: ' + dog.breed);
},
};
const animalGreeter: { greet(animal: Animal): void } = dogGreeter;
animalGreeter.greet({ name: 'Generic' });A function-typed property is checked strictly
The same handler stored as a function-typed property gets the strict check. The dog handler is refused where an animal handler is required.
interface Animal { name: string }
interface Dog extends Animal { breed: string }
const dogHandler = (dog: Dog) => console.log(dog.breed);
const strictHandler: { handle: (animal: Animal) => void } = { handle: dogHandler };Array covariance betrays the same way
The dog array widens to an animal array without a complaint. Writing a plain animal through the wide view leaves a dog slot with no breed.
interface Animal { name: string }
interface Dog extends Animal { breed: string }
const dogs: Dog[] = [{ name: 'Rex', breed: 'Collie' }];
const animals: Animal[] = dogs;
animals.push({ name: 'Mog' });
console.log(dogs.length);
console.log(dogs[1].breed);A producer and consumer pair stays safe
The transfer function takes a dog producer and an animal consumer. Each side substitutes in its safe direction, so every moved value fits its reader.
interface Animal { name: string }
interface Dog extends Animal { breed: string }
interface Producer<out T> {
next(): T;
}
interface Consumer<in T> {
take(value: T): void;
}
function transfer(source: Producer<Dog>, sink: Consumer<Animal>): void {
sink.take(source.next());
}
const kennel: Producer<Dog> = { next: () => ({ name: 'Rex', breed: 'Collie' }) };
const logbook: Consumer<Animal> = { take: (a) => console.log('moved ' + a.name) };
transfer(kennel, logbook);Example
interface Animal { name: string }
interface Dog extends Animal { breed: string }
interface Intake<in T> {
handler: (value: T) => void;
}
const animalIntake: Intake<Animal> = {
handler: (a) => console.log('intake ' + a.name),
};
const dogIntake: Intake<Dog> = animalIntake;
const rex: Dog = { name: 'Rex', breed: 'Collie' };
dogIntake.handler(rex);
console.log(rex.breed);Key points
- A method parameter accepts a narrower function under strictFunctionTypes, and that looseness is called bivariance.
- A function-typed property accepts no such substitution, since its parameter is checked in one direction only.
- The loose method check can pass a value the receiving code never expected, and the failure appears at runtime.
- Array<T> is treated as a producer of T, so an array of a narrower type widens to an array of a wider type.
- That array widening is unsound, since the wider view can write values the narrower array cannot hold.
- Splitting a design into a producer and a consumer keeps every substitution in its safe direction.
In this example, the intake handler is a function-typed property, so the strict check applies and only the safe direction compiles. An animal handler serves the dog intake because it accepts every animal a dog value could be.
Note
The strict property form is the safer default for handlers and callbacks stored in object types.
Try it Yourself
Edit the code, then press Run.
Running is simulated ยท TypeScript Playground
Exercise
Why did the animalGreeter call print undefined instead of failing to compile?