Variance is a property of how subtyping relationships between complex types (like function types or generic types) relate to the subtyping relationships of their constituent types.
Let’s see it in action.
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Covariance in action: A function that returns Dog is assignable to a function that returns Animal.
let getDog: () => Dog = () => ({ name: "Buddy", breed: "Golden Retriever" });
let getAnimal: () => Animal = getDog; // This is allowed!
// Contravariance in action: A function that accepts Animal is assignable to a function that accepts Dog.
let processDog: (dog: Dog) => void = (dog) => console.log(`Processing dog: ${dog.breed}`);
let processAnimal: (animal: Animal) => void = processDog; // This is also allowed!
// Invariance (neither co- nor contra-): A function that accepts Dog and returns Animal is NOT assignable to a function that accepts Animal and returns Dog.
let processDogReturnAnimal: (dog: Dog) => Animal = (dog) => ({ name: dog.breed });
// let processAnimalReturnDog: (animal: Animal) => Dog = (animal) => ({ name: animal.name, breed: "Unknown" });
// processAnimalReturnDog = processDogReturnAnimal; // Error: Type '(dog: Dog) => Animal' is not assignable to type '(animal: Animal) => Dog'.
Variance is how TypeScript decides if you can substitute one type for another when those types are "complex" – meaning they contain other types. Think about simple types first: if Dog is a subtype of Animal (which it is, because Dog has all the properties of Animal plus more), then you can pass a Dog object anywhere an Animal object is expected. This is called covariance for the "output" side of things.
The real complexity comes with types that contain other types, like function types. A function type (param: T) => U has two "positions" where T and U appear: param is in a contravariant position, and U (the return type) is in a covariant position. This is because the subtyping rule for functions is: (A) => X is assignable to (B) => Y if and only if B is a subtype of A (contravariance on the parameter) AND X is a subtype of Y (covariance on the return type).
Let’s break down why. Covariance on the return type makes intuitive sense. If a function is supposed to give you an Animal, and you have a function that actually gives you a Dog (which is a more specific Animal), that’s perfectly fine. You can still treat the Dog as an Animal.
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Covariance: () => Dog is assignable to () => Animal
const getDog: () => Dog = () => ({ name: "Fido", breed: "Labrador" });
const getAnimal: () => Animal = getDog; // This works! 'getDog' can be used where 'getAnimal' is expected.
const animal = getAnimal(); // animal is of type Animal, but it's actually a Dog object.
console.log(animal.name); // OK, 'name' is on Animal.
// console.log(animal.breed); // Error: Property 'breed' does not exist on type 'Animal'.
Contravariance on the parameter is trickier. If you have a function that expects an Animal, and you give it a function that only knows how to process Dogs, that’s also fine. Why? Because when your Dog-processing function is called, it will only be given Dogs. It will never be called with a plain Animal that doesn’t have a breed. The function is safer to use because it accepts a more general type (Animal) but is implemented to handle a more specific type (Dog).
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Contravariance: (animal: Animal) => void is assignable to (dog: Dog) => void
const processAnimal: (animal: Animal) => void = (animal) => {
console.log(`Processing generic animal: ${animal.name}`);
};
const processDog: (dog: Dog) => void = (dog) => {
console.log(`Processing dog with breed: ${dog.breed}`);
};
// You can assign processDog to a variable expecting processAnimal.
// This means you can pass 'processDog' to a function that expects a callback
// that takes an 'Animal'.
const processAnything: (animal: Animal) => void = processDog;
// When processAnything is called, it will receive an Animal.
// If the Animal happens to be a Dog, processDog will handle it correctly.
// If the Animal is NOT a Dog, processDog would throw an error if it tried to access 'breed'.
// This is why TypeScript prevents assigning a function that accepts a *more specific* type
// to a variable expecting a *more general* type in the parameter position.
// But assigning a function that accepts a *more general* type to a variable expecting
// a *more specific* type is safe.
processAnything({ name: "Rover", breed: "Poodle" }); // Works, Rover is a Dog
// processAnything({ name: "Fluffy" }); // This would be an error if 'processAnything' was allowed to be 'processDog' and was called with this.
When you have generic types like Array<T>, Promise<T>, or custom generics, their variance depends on how T is used within them.
- Covariant Generics: If
Tappears only in a covariant position (like the return type of a function, or the element type of an array being read from), the generic type is covariant.Array<Dog>is a subtype ofArray<Animal>. This is why you can doconst animals: Animal[] = [new Dog()];but notconst dogs: Dog[] = [new Animal()];. - Contravariant Generics: If
Tappears only in a contravariant position (like the parameter type of a function), the generic type is contravariant. This is less common for built-in types but crucial for understanding custom generics. - Invariant Generics: If
Tappears in both covariant and contravariant positions, or in a non-variant position (like a parameter in a method that both reads and writes to an internal array), the generic type is invariant.Array<T>is generally invariant because you can both read (Tis covariant) and write (Tis contravariant) to it. You can’t sayArray<Dog>is assignable toArray<Animal>directly.
TypeScript uses in and out keywords (though not directly in the syntax for built-in types) to denote contravariance and covariance, respectively, in generic type definitions. For user-defined generic types, you can explicitly mark them:
// Example of explicit variance annotation (conceptual, not actual TS syntax)
// type ReadonlyArray<T> = ... // Covariant (T is only read)
// type Consumer<T> = ... // Contravariant (T is only written to)
// type MutableArray<T> = ... // Invariant (T is read and written)
The key insight is that variance is about safety. TypeScript’s variance rules ensure that when you substitute one type for another based on subtyping, you don’t accidentally break the program by trying to do something invalid with the substituted type. For example, if you have Array<Dog> and try to assign it to Array<Animal>, you could potentially add a non-Dog Animal to the Array<Dog>, which would be a runtime error. Hence, Array<T> is invariant.
The most surprising thing about variance is how it applies to mutable data structures. While Array<Dog> is not assignable to Array<Animal>, a Dog[] can be assigned to an Animal[] in JavaScript at runtime because JavaScript arrays are dynamically typed. TypeScript’s invariance for mutable generic types like Array<T> is a compile-time safety mechanism to prevent a class of runtime errors that would occur if you assumed a mutable array of a more specific type was compatible with a mutable array of a more general type. This means you often need to use type assertions or create new arrays to bridge these gaps safely.
The next concept you’ll likely encounter is how to manage variance in your own custom generic types, particularly when dealing with complex data structures or callbacks.