๐ŸŽ“ A1Learner
๐Ÿ”
0 of 37 lessons completed

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?