October 16, 2026Nwankwo Ernest Onyebuchi16 min read
Was this useful?
TypeScript Variance Explained: Covariance, Contravariance and Invariance
Why can you assign a Dog[] to an Animal[], yet assigning a (value: Dog) => void to a (value: Animal) => void is an error? Both look like the same "a dog is an animal" relationship, but TypeScript treats them differently. The rule behind the difference is TypeScript variance: how a subtype relationship between two types carries over to types built from them, such as arrays, promises, generics and functions.
This guide explains covariance, contravariance and invariance (plus the bivariance TypeScript still allows in a few places) with examples you can paste into your editor. You will also learn what strictFunctionTypes changes, why arrays are technically unsound, and how the in and out variance annotations added in TypeScript 4.7 work. Every snippet was compiled in strict mode with TypeScript 7.0, and the lines marked as errors do produce errors.
Key Takeaways
Covariant means the relationship keeps its direction. Dog is a subtype of Animal, so Producer<Dog> is usable as Producer<Animal>. This fits types that only output values.
Contravariant means the direction reverses. is usable as . This fits types that only values.
Invariant means neither direction is allowed. This fits types that both read and write the same T.
Bivariant means both directions are allowed. TypeScript still does this for method parameters and when strictFunctionTypes is off.
A type parameter's variance depends on the position where it is used: return type, parameter, or property.
What Is Variance in TypeScript?
A type S is a subtype of T when a value of type S can be used anywhere a T is expected. TypeScript decides this structurally: Dog is a subtype of Animal because it has every member of Animal and more.
The examples in this article reuse these three types:
Variance answers one question: if Dog is a subtype of Animal, what is the relationship between F<Dog> and F<Animal>, where F is a generic type, an array, or a function type?
Variance
F<Dog> to F<Animal>
F<Animal> to F<Dog>
Typical position
Covariant
Allowed
Error
Return types, read-only data
Contravariant
Error
Allowed
Function parameters
Invariant
Error
Error
Read and write together
Bivariant
Allowed
Allowed
Method parameters (legacy leniency)
"To" here means assignment: F<Dog> to F<Animal> means let a: F<Animal> = valueOfF<Dog>.
Covariance: Types That Only Produce Values
If something only hands out values, a more specific type is always a safe substitute. A function that returns a Dog can stand in for a function that promises an Animal, because every caller that expects an Animal is happy to receive a dog.
declare let getDog: () => Dog;
let getAnimal: () => Animal = getDog; // OK: return types are covariant
getDog = getAnimal; // Error: Animal is missing 'breed'
The same logic applies to read-only collections and promises:
declare const dogs: readonly Dog[];
const animals: readonly Animal[] = dogs; // OK
declare let dogPromise: Promise<Dog>;
const animalPromise: Promise<Animal> = dogPromise; // OK
Covariance is the direction most developers expect, because it matches how inheritance feels: more specific things can be used in place of more general things.
Contravariance: Types That Only Consume Values
Function parameters flip the direction. Imagine you need a handler that deals with dogs. A handler that can deal with any animal is a perfectly good replacement. A handler that only understands dogs is not a replacement for one that must deal with any animal, because someone could hand it a cat.
type Handler<T> = (value: T) => void;
declare let animalHandler: Handler<Animal>;
declare let dogHandler: Handler<Dog>;
dogHandler = animalHandler; // OK: Handler<Animal> can handle any Dog
animalHandler = dogHandler; // Error
With strict enabled, the compiler reports the second assignment like this:
Type 'Handler<Dog>' is not assignable to type 'Handler<Animal>'.
Property 'breed' is missing in type 'Animal' but required in type 'Dog'.
Notice that the direction is the reverse of the return-type example. Handler<Animal> is the more general handler, and it is the one that works in the more specific slot. That reversal is what "contra" refers to.
Why strictFunctionTypes Matters
Contravariant parameter checking is controlled by the strictFunctionTypes compiler option. According to the TypeScript 2.6 release notes, it is part of the strict family, so it is on by default when you enable strict, and under it function parameter positions are checked contravariantly instead of bivariantly. The strictFunctionTypes reference shows the unsafe case it prevents: assigning a function that expects a string to a type that accepts string | number, then calling it with a number, which crashes at runtime.
If your project does not use "strict": true, check your tsconfig.json and turn this flag on. The strict reference explains how the family of strict options fits together.
Invariance: Types That Read and Write
What if a type both produces and consumes T? A store that returns a value and also accepts one cannot be safely substituted in either direction.
Each assignment fails for its own reason. Store<Dog> cannot replace Store<Animal> because its set would receive a Cat it cannot accept. Store<Animal> cannot replace Store<Dog> because its get might return a Cat where a Dog is required. Here is the real compiler output for the first assignment:
Type 'Store<Dog>' is not assignable to type 'Store<Animal>'.
Types of property 'set' are incompatible.
Type '(value: Dog) => void' is not assignable to type '(value: Animal) => void'.
Types of parameters 'value' and 'value' are incompatible.
Property 'breed' is missing in type 'Animal' but required in type 'Dog'.
The in out modifiers in this example are explicit variance annotations, covered below. This particular interface is invariant even without them, because both members use function-property syntax.
Bivariance: Where TypeScript Is Deliberately Lenient
Bivariance means assignment works in both directions. The Type Compatibility page of the handbook describes it: when function parameters are compared, assignment succeeds if either parameter type is assignable to the other.
Under strictFunctionTypes, bivariance survives in one important place: method syntax. The release notes explain that the stricter check applies to function types but not to those that originate in method or constructor declarations, specifically so that generic classes and interfaces such as Array<T> keep relating covariantly.
The difference between the two declaration styles is easy to see side by side:
interface MethodComparer<T> {
compare(a: T, b: T): number; // method syntax: bivariant parameters
}
interface PropComparer<T> {
compare: (a: T, b: T) => number; // property syntax: contravariant parameters
}
declare let methodDog: MethodComparer<Dog>;
declare let methodAnimal: MethodComparer<Animal>;
methodAnimal = methodDog; // Allowed, even though it is unsound
methodDog = methodAnimal; // Allowed
declare let propDog: PropComparer<Dog>;
declare let propAnimal: PropComparer<Animal>;
propDog = propAnimal; // OK
propAnimal = propDog; // Error
The methodAnimal = methodDog line is unsafe: a MethodComparer<Animal> may be called with a cat, but the object behind it only knows how to compare dogs. TypeScript accepts it anyway for compatibility with existing JavaScript and DOM patterns.
Practical rule: if you want strict contravariance for a callback member, declare it as a property with a function type, not as a method.
Variance Cheat Sheet: Position Decides Everything
A type parameter has no variance on its own. Its variance is determined by where it appears inside the type.
Where T appears
Variance in TypeScript
Return type of a function
Covariant
Parameter of a function type (strictFunctionTypes on)
Contravariant
Parameter of a method declaration
Bivariant
Property (including readonly)
Covariant in practice
readonly T[] or Promise<T>
Covariant
T[] (mutable array)
Covariant, which is unsound
Both a getter-style output and a setter-style input
Invariant
Parameter of a callback that is itself a parameter
Covariant (two flips)
Callbacks: Variance Flips Twice
When a function type appears inside a parameter of another function type, the direction flips twice and ends up covariant again. A subscription function is the classic real-world case:
type Subscribe<T> = (listener: (value: T) => void) => () => void;
declare let subscribeToDogs: Subscribe<Dog>;
declare let subscribeToAnimals: Subscribe<Animal>;
subscribeToAnimals = subscribeToDogs; // OK: Subscribe is covariant in T
subscribeToDogs = subscribeToAnimals; // Error
The reasoning follows the data flow. A Subscribe<Dog> only ever emits dogs to the listener you give it, so it is a source of values, and sources are covariant. The listener itself is contravariant in T, and the extra layer of "parameter of a parameter" cancels the flip. To work out the variance of a complex type, count how many times the direction flips between the type parameter and the outside: an even number gives covariant, an odd number gives contravariant.
Where TypeScript Is Unsound: Arrays and Mutable Properties
TypeScript's type system is intentionally not perfectly sound. Two cases matter in everyday code.
Mutable arrays are treated as covariant.
const dogList: Dog[] = [{ name: "Rex", breed: "Labrador" }];
const animalList: Animal[] = dogList; // Allowed
animalList.push({ name: "Tom" }); // Compiles, but dogList now holds a non-Dog
After the push, dogList[1].breed is undefined at runtime even though its static type says string. This happens because push is declared as a method, so its parameter is compared bivariantly, which keeps Array<T> covariant.
Mutable properties are covariant too.
type Holder<T> = { value: T };
const dogHolder: Holder<Dog> = { value: { name: "Rex", breed: "Labrador" } };
const animalHolder: Holder<Animal> = dogHolder; // Allowed
animalHolder.value = { name: "Tom" }; // Compiles, but dogHolder.value is no longer a Dog
In a fully sound system, a type you can both read and write would be invariant. TypeScript trades that strictness for convenience. You can still protect yourself:
Accept readonly Animal[] instead of Animal[] in function parameters when you do not mutate the list. A Dog[] can then be passed in safely.
Split read and write capabilities into separate interfaces (shown below).
Use variance annotations on your own generic types to make the intended direction explicit.
function names(list: readonly Animal[]): string[] {
return list.map((animal) => animal.name);
}
names(dogList); // OK, and the function cannot push into your array
Explicit Variance Annotations: in, out and in out
TypeScript 4.7 added optional variance annotations for type parameters. The official TypeScript 4.7 announcement describes them: out marks a type parameter as covariant, in marks it as contravariant, and in out marks it as invariant.
type Producer<out T> = () => T;
type Consumer<in T> = (value: T) => void;
interface Store<in out T> {
get: () => T;
set: (value: T) => void;
}
A handy mnemonic: out means T only appears in output positions, and in means T only appears in input positions.
The Compiler Checks Your Annotation
If you annotate a type parameter incorrectly, TypeScript tells you:
interface Wrong<out T> {
get: () => T;
set: (value: T) => void; // Error: T is used in an input position
}
Type 'Wrong<sub-T>' is not assignable to type 'Wrong<super-T>' as implied by variance annotation.
Types of property 'set' are incompatible.
...
Annotations can also tighten method-syntax members. In this verified example, in out forces invariance even though the members are declared as methods:
interface AnnotatedStore<in out T> {
get(): T;
set(value: T): void;
}
declare let annotatedDog: AnnotatedStore<Dog>;
declare let annotatedAnimal: AnnotatedStore<Animal>;
annotatedAnimal = annotatedDog; // Error, despite method syntax
You can apply annotations to type parameters of interfaces, classes, and type aliases for object, function, constructor and mapped types.
Why Annotate at All?
The TypeScript team gives two reasons in the 4.7 announcement. First, annotations help readers see at a glance whether a type parameter is read, written, or both. Second, TypeScript already infers variance internally as an optimization, and explicit annotations can improve both accuracy and type-checking speed for deeply recursive types where the inferred answer is expensive or ambiguous.
The same announcement also advises against annotating every type parameter. It is possible to declare something stricter than necessary, and the compiler will not stop you. Use annotations thoughtfully, mainly for library types, documentation of intent, and recursive generics that slow down the checker. The implementing pull request on GitHub contains the full design discussion.
Practical Patterns That Use Variance Well
1. Accept the Widest Input, Return the Narrowest Output
Because parameters are contravariant and return types are covariant, API design follows a simple rule: ask for the most general type you can work with, and promise the most specific type you can deliver.
type Handler<T> = (value: T) => void;
function onDogAdopted(handler: Handler<Dog>) {
handler({ name: "Rex", breed: "Labrador" });
}
const logAnimal: Handler<Animal> = (animal) => console.log(animal.name);
onDogAdopted(logAnimal); // OK: a general handler works for a specific event
2. Split Read and Write Interfaces
Instead of one invariant type that blocks substitution everywhere, expose a covariant reader and a contravariant writer, then combine them only where both are needed.
interface Reader<out T> {
get(id: string): T | undefined;
}
interface Writer<in T> {
save(item: T): void;
}
interface Repository<in out T> extends Reader<T>, Writer<T> {}
declare const dogRepo: Repository<Dog>;
declare const animalRepo: Repository<Animal>;
function printFirst(reader: Reader<Animal>) {
return reader.get("1")?.name;
}
function saveDog(writer: Writer<Dog>, dog: Dog) {
writer.save(dog);
}
printFirst(dogRepo); // OK: Repository<Dog> is usable as a Reader<Animal>
saveDog(animalRepo, { name: "Rex", breed: "Labrador" }); // OK: a Writer<Animal> can save dogs
Functions now declare the minimum capability they need, which makes them accept more arguments safely.
3. Use never and unknown as the Extremes
never is the bottom type and unknown is the top type, so they sit at the two ends of every variance relationship:
declare let neverProducer: () => never;
const dogProducer: () => Dog = neverProducer; // OK: never is a subtype of everything
declare let unknownHandler: Handler<unknown>;
declare let dogHandler: Handler<Dog>;
dogHandler = unknownHandler; // OK: a handler for anything handles a Dog
This is why a Handler<unknown> is the most reusable callback type, and why a producer of never fits any producer slot.
4. Treat Generated API Types as Read-Only Data
Types generated from API responses describe data you receive, which makes them naturally covariant. If you create interfaces from sample payloads with the JSON to TypeScript Converter, prefer passing them around as read-only values and readonly arrays, and avoid mutating shared objects. Union-based models behave the same way: a literal such as "red" is a subtype of "red" | "green" | "blue", and our guide to TypeScript discriminated unions shows how to narrow those safely.
How to Probe Variance in Your Own Code
You can ask the compiler directly whether one type is assignable to another with a tiny helper. Wrapping both sides in tuples prevents distribution over unions:
type Assignable<From, To> = [From] extends [To] ? true : false;
type DogToAnimal = Assignable<Dog, Animal>; // true
type HandlerWide = Assignable<Handler<Animal>, Handler<Dog>>; // true (contravariant)
type HandlerNarrow = Assignable<Handler<Dog>, Handler<Animal>>; // false
Hover over each alias in your editor to see the result. This is a quick way to confirm how a new generic type behaves before you rely on it, and it works well inside type-level unit tests.
Common Variance Mistakes
Assuming Dog[] is safe as Animal[]. It compiles, but any write through the wider reference can corrupt the original. Prefer readonly Animal[] for parameters.
Declaring callbacks with method syntax and expecting strictness. Method parameters stay bivariant. Use callback: (value: T) => void property syntax when you want contravariance.
Turning off strictFunctionTypes to silence an error. The error usually points at a real substitutability problem. Fix the type instead.
Reversing the direction in your head. Remember that the narrower parameter type is the one that cannot substitute, and the wider parameter type is the one that can.
Annotating everything with in and out. Over-constraining a type can block valid assignments. Annotate where it communicates intent or fixes a real checker problem.
Mixing reads and writes in one generic parameter by default. If one interface does both, expect invariance and plan for it, or split the interface.
Frequently Asked Questions
What is the difference between covariance and contravariance in TypeScript?
Covariance keeps the subtype direction: if Dog is a subtype of Animal, then Producer<Dog> is a subtype of Producer<Animal>. Contravariance reverses it: Consumer<Animal> is a subtype of Consumer<Dog>. Covariance applies to values a type produces, and contravariance applies to values a type consumes.
Is TypeScript covariant or contravariant?
Both, depending on the position. Return types and read-only data are covariant, function parameters are contravariant when strictFunctionTypes is on, and types that both read and write a parameter are invariant. Method parameters and mutable arrays are treated more leniently.
Why can I assign Dog[] to Animal[] in TypeScript?
TypeScript treats Array<T> as covariant for convenience, because array methods such as push are declared with method syntax and their parameters are compared bivariantly. The result is unsound for writes, so use readonly arrays when you only need to read.
What does strictFunctionTypes do?
It makes TypeScript check function parameters contravariantly instead of bivariantly for function types. It is included in the strict option and does not apply to parameters declared with method syntax.
Do I need to write in and out annotations?
No. They are optional. TypeScript infers variance automatically. Annotations are useful for documenting intent, catching mistakes, and improving checker accuracy and performance on complex recursive types.
Conclusion
TypeScript variance comes down to one question: is a type parameter used to produce values, consume them, or both? Producers are covariant, consumers are contravariant, and types that do both are invariant. TypeScript relaxes the rules for method parameters and mutable containers like arrays, so you gain flexibility at the cost of some soundness, and you should compensate with readonly parameters, split read/write interfaces, and function-property syntax where strictness matters.
Turn on strict, design APIs that accept wide inputs and return narrow outputs, and reach for in and out annotations when they make intent clearer. Once you can see the direction of each position, assignability errors that once seemed arbitrary become predictable.