What is variance in TypeScript?
Type variance describes how subtyping relationships between more complex types relate to the subtyping relationships of their component types. In TypeScript, understanding variance is crucial for correctly assigning and using types, especially with generics, functions, and mutable data structures, to prevent unexpected type errors and ensure type safety.
What is Type Variance?
At its core, variance defines how a subtyping relationship between types A and B (A is a subtype of B, written A <: B) translates to a subtyping relationship between C<A> and C<B>, where C is a generic type constructor (like Array<T> or (param: T) => void).
Types of Variance
1. Covariance
A type C<T> is covariant in T if A <: B implies C<A> <: C<B>. The subtyping relationship is preserved in the same direction. This is safe for 'read-only' positions, where values are only produced or read.
Examples in TypeScript: Return types of functions and read-only array types (readonly T[]). If Dog extends Animal, then () => Dog is a subtype of () => Animal, and readonly Dog[] is a subtype of readonly Animal[].
2. Contravariance
A type C<T> is contravariant in T if A <: B implies C<B> <: C<A>. The subtyping relationship is reversed. This is safe for 'write-only' positions, where values are only consumed or written.
Example in TypeScript: Function parameters with strictFunctionTypes enabled. If Dog extends Animal, then (param: Animal) => void is a subtype of (param: Dog) => void. This is because a function expecting an Animal can safely be passed a Dog (which is an Animal), while a function expecting a Dog cannot safely be passed an arbitrary Animal (which might not be a Dog).
3. Bivariance
A type C<T> is bivariant in T if A <: B implies C<A> <: C<B> AND C<B> <: C<A>. Essentially, subtyping is ignored for that position, making the types interchangeable from a subtyping perspective (though not necessarily identical). Bivariance is generally considered unsound, but can be pragmatic.
Example in TypeScript: Prior to strictFunctionTypes (or for methods, even with strictFunctionTypes), function parameters were bivariant. This allowed for greater flexibility in callback signatures, but at the cost of type safety.
4. Invariance
A type C<T> is invariant in T if A <: B implies neither C<A> <: C<B> nor C<B> <: C<A>. The subtyping relationship is not preserved in either direction. This is the safest but most restrictive form of variance, used for positions that are both read and written.
Example in TypeScript: Mutable arrays (T[]) are effectively invariant. If Dog extends Animal, Dog[] is neither a subtype nor a supertype of Animal[]. This prevents issues where you might add an Animal to a Dog[] (contravariant risk) or retrieve a Dog from an Animal[] expecting it to be a specific Dog type (covariant risk).
Variance in TypeScript's Type System
TypeScript employs a structural type system and generally aims for soundness while also prioritizing developer ergonomics. Its variance rules can be subtle, especially concerning functions and mutable state.
By default, TypeScript's function return types are covariant. Function parameter types were historically bivariant, which was unsound but pragmatic. With the strictFunctionTypes compiler option (highly recommended), function parameters become contravariant, which is type-safe. However, for methods (functions defined within an object or class), TypeScript retains bivariance by default for compatibility with common JavaScript patterns.
Mutable data structures like arrays (T[]) are generally treated as invariant by ensuring that operations like push or pop maintain type safety. For example, you cannot assign Dog[] to Animal[] directly if you intend to mutate it, as it would be unsafe.
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }
// Covariance of return types (always safe)
const getAnimal = (): Animal => ({ name: "Generic Animal" });
const getDog = (): Dog => ({ name: "Buddy", breed: "Golden Retriever" });
let animalProducer: () => Animal;
animalProducer = getDog; // OK: () => Dog <: () => Animal (covariant return)
// animalProducer = getAnimal; // Also OK: () => Animal <: () => Animal
// Contravariance of parameters (with strictFunctionTypes: true)
// Let's assume 'strictFunctionTypes: true' in tsconfig.json
let processAnimal: (a: Animal) => void;
let processDog: (d: Dog) => void;
processAnimal = (a: Animal) => console.log(`Processing Animal: ${a.name}`);
processDog = (d: Dog) => console.log(`Processing Dog: ${d.name} (${d.breed})`);
// OK: A function that can process any Animal can certainly process a Dog
processDog = processAnimal; // (a: Animal) => void <: (d: Dog) => void (contravariant parameter)
// Error with strictFunctionTypes: Type '(d: Dog) => void' is not assignable to type '(a: Animal) => void'.
// A Dog processor expects a Dog, but an Animal processor might pass any Animal (which may not be a Dog).
// processAnimal = processDog;
// If strictFunctionTypes were OFF, both assignments between processAnimal and processDog would be allowed (bivariant).