TypeScript Variance: Covariance and Contravariance

Variance describes how subtyping relationships between types transfer to their generic containers. When you have a type hierarchy like `Labrador extends Dog extends Animal`, it's intuitive that you...

Key Insights

  • Variance determines how subtyping relationships transfer to generic types—covariance preserves the direction (Dog[] is assignable to Animal[]), while contravariance reverses it (a function accepting Animal is assignable to one accepting Dog)
  • TypeScript’s default bivariance for methods creates type safety holes; enable strictFunctionTypes to enforce contravariance for function parameters and catch potential runtime errors
  • Understanding variance is critical for writing type-safe generic functions, React components, and event handlers—the wrong variance can allow invalid data to slip through your type system

Introduction to Variance

Variance describes how subtyping relationships between types transfer to their generic containers. When you have a type hierarchy like Labrador extends Dog extends Animal, it’s intuitive that you can use a Labrador wherever a Dog is expected. But what happens when these types are wrapped in arrays, functions, or other generic types? Can you use Dog[] where Animal[] is expected? What about functions that accept or return these types?

These questions matter because getting variance wrong leads to runtime errors that TypeScript should prevent. The type system needs rules for when Container<Dog> can substitute for Container<Animal>, and these rules vary depending on whether the type appears in input positions (contravariant), output positions (covariant), or both (invariant).

Let’s establish a basic type hierarchy we’ll use throughout:

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
  bark() {
    console.log('Woof!');
  }
}

class Labrador extends Dog {
  color: 'yellow' | 'black' | 'chocolate';
  constructor(name: string, color: 'yellow' | 'black' | 'chocolate') {
    super(name);
    this.color = color;
  }
}

With this hierarchy, Labrador is a subtype of Dog, which is a subtype of Animal. The question is: how does this relationship transfer to generic types?

Covariance Explained

Covariance means the subtyping relationship is preserved. If Dog is a subtype of Animal, then Container<Dog> is a subtype of Container<Animal>. This works safely when the type appears in output positions—when you’re reading values out of the container.

TypeScript arrays are covariant, which allows this assignment:

const dogs: Dog[] = [new Dog('Rex'), new Dog('Max')];
const animals: Animal[] = dogs; // Valid: arrays are covariant

// Safe operations - we're only reading
animals.forEach(animal => console.log(animal.name));

This seems convenient, but it creates a soundness hole:

const dogs: Dog[] = [new Dog('Rex')];
const animals: Animal[] = dogs;

// TypeScript allows this, but it's unsafe!
animals.push(new Animal('Generic Animal'));

// Now dogs[1] is an Animal, not a Dog
dogs[1].bark(); // Runtime error: bark is undefined

Arrays are covariant because it’s practical—the alternative would make arrays much harder to use. TypeScript chooses pragmatism over perfect soundness here.

Function return types are also covariant, which is sound:

type AnimalGetter = () => Animal;
type DogGetter = () => Dog;

const getDog: DogGetter = () => new Dog('Buddy');
const getAnimal: AnimalGetter = getDog; // Valid and safe

const animal = getAnimal(); // Returns a Dog, which is an Animal
console.log(animal.name); // Always safe

This works because if you expect a function that returns an Animal, getting a Dog is always safe—Dog has everything Animal has.

Read-only properties also demonstrate safe covariance:

interface ReadonlyBox<out T> {
  readonly value: T;
}

const dogBox: ReadonlyBox<Dog> = { value: new Dog('Charlie') };
const animalBox: ReadonlyBox<Animal> = dogBox; // Safe: covariant position

console.log(animalBox.value.name); // Safe to read

The out modifier explicitly marks the type parameter as covariant, documenting that T only appears in output positions.

Contravariance Explained

Contravariance reverses the subtyping relationship. If Dog is a subtype of Animal, then Container<Animal> is a subtype of Container<Dog>. This applies to function parameters—input positions.

Consider a function that processes dogs:

type DogHandler = (dog: Dog) => void;
type AnimalHandler = (animal: Animal) => void;

const handleAnimal: AnimalHandler = (animal) => {
  console.log(animal.name);
};

const handleDog: DogHandler = handleAnimal; // Valid: contravariant

This is safe because if you need a function that handles Dogs, a function that handles any Animal works—it won’t try to access Dog-specific properties. The reverse would be unsafe:

const handleDog: DogHandler = (dog) => {
  dog.bark(); // Assumes dog has bark method
};

// This would be unsafe if TypeScript allowed it
const handleAnimal: AnimalHandler = handleDog; // Error with strictFunctionTypes

If this were allowed, you could pass any Animal to handleAnimal, but the function expects Dog-specific methods.

Event handlers demonstrate contravariance in practice:

interface DogEvent {
  target: Dog;
}

interface AnimalEvent {
  target: Animal;
}

type DogEventHandler = (event: DogEvent) => void;

const handleAnimalEvent = (event: AnimalEvent) => {
  console.log(event.target.name);
};

// Valid: function accepting Animal can handle Dog events
const dogHandler: DogEventHandler = handleAnimalEvent;

The strictFunctionTypes compiler option enforces this contravariance. Without it, TypeScript uses bivariance for function parameters, which is unsound but was necessary for backward compatibility.

Bivariance and Type Safety Issues

Bivariance means a type is both covariant and contravariant—the worst of both worlds for type safety. TypeScript historically used bivariance for method parameters, creating significant soundness holes.

Consider this example without strictFunctionTypes:

// With strictFunctionTypes: false (default in older TypeScript)
interface AnimalHandler {
  handle(animal: Animal): void;
}

interface DogHandler {
  handle(dog: Dog): void;
}

const dogHandler: DogHandler = {
  handle(dog: Dog) {
    dog.bark(); // Assumes dog has bark
  }
};

// Bivariance allows this unsafe assignment
const animalHandler: AnimalHandler = dogHandler;
animalHandler.handle(new Animal('Generic')); // Runtime error!

The syntax you use matters. Method syntax (shown above) is bivariant, but property syntax with function types is contravariant:

interface DogHandler {
  handle: (dog: Dog) => void; // Property syntax
}

const dogHandler: DogHandler = {
  handle: (dog: Dog) => dog.bark()
};

// Error with strictFunctionTypes: true
const animalHandler: AnimalHandler = dogHandler;

Enable strictFunctionTypes in your tsconfig.json:

{
  "compilerOptions": {
    "strictFunctionTypes": true,
    "strict": true
  }
}

The strict flag includes strictFunctionTypes, so using strict mode gives you proper contravariance checking. This catches errors at compile time that would otherwise manifest as runtime failures.

Practical Applications

Understanding variance is essential for writing type-safe generic code. Generic constraints need careful variance consideration:

interface Mapper<in TInput, out TOutput> {
  map(input: TInput): TOutput;
}

// TInput is contravariant (in), TOutput is covariant (out)
const animalToString: Mapper<Animal, string> = {
  map: (animal) => animal.name
};

const dogToString: Mapper<Dog, string> = animalToString; // Valid

React event handlers demonstrate variance in real applications:

interface ButtonProps {
  onClick: (event: MouseEvent) => void;
}

// More general handler works for specific events
const handleUIEvent = (event: UIEvent) => {
  console.log('Event triggered');
};

const Button: React.FC<ButtonProps> = ({ onClick }) => {
  return <button onClick={onClick}>Click me</button>;
};

// Valid: contravariant parameter
<Button onClick={handleUIEvent} />

API response mapping requires understanding covariance:

interface ApiResponse<out T> {
  data: T;
  status: number;
}

function processAnimalResponse(response: ApiResponse<Animal>) {
  console.log(response.data.name);
}

const dogResponse: ApiResponse<Dog> = {
  data: new Dog('API Dog'),
  status: 200
};

processAnimalResponse(dogResponse); // Valid: covariant type parameter

Common Pitfalls and Best Practices

The most common mistake is assuming arrays are invariant and being surprised by runtime errors:

// Pitfall: Mutating covariant arrays
const dogs: Dog[] = [new Dog('Rex')];
const animals: Animal[] = dogs;
animals.push(new Animal('Cat')); // Compiles but wrong!

// Solution: Use readonly for covariance without mutation
const dogs: readonly Dog[] = [new Dog('Rex')];
const animals: readonly Animal[] = dogs; // Safe
// animals.push(...) // Error: push doesn't exist on readonly

Avoid unsafe type assertions that bypass variance checks:

// Dangerous: Casting away variance
const handleDog = (dog: Dog) => dog.bark();
const handleAnimal = handleDog as (animal: Animal) => void; // Unsafe!

// Better: Write properly typed functions
const handleAnimal = (animal: Animal) => {
  if (animal instanceof Dog) {
    animal.bark();
  }
};

For generic functions, use variance annotations to document intent:

// Good: Explicit variance
interface Producer<out T> {
  produce(): T;
}

interface Consumer<in T> {
  consume(value: T): void;
}

// Clear that T is only produced, never consumed
function mapProducer<T, U>(
  producer: Producer<T>,
  mapper: (value: T) => U
): Producer<U> {
  return {
    produce: () => mapper(producer.produce())
  };
}

Always enable strictFunctionTypes. The minor inconvenience of stricter checking prevents entire classes of runtime errors. If you’re working with callbacks, event handlers, or generic types, variance determines whether your code is truly type-safe or just appears to be. Understanding these concepts transforms TypeScript from a type annotation system into a genuine safety tool.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.