Variance Annotations (in/out) for TypeScript Generics
Understand covariance, contravariance, and how TypeScript's in and out annotations make generic type relationships explicit.
What you'll learn
- ✓What covariance and contravariance mean in practice
- ✓How to use in and out annotations on generic parameters
- ✓When variance annotations prevent subtle type bugs
Prerequisites
- •TypeScript generics
- •Understanding of subtype relationships
TypeScript 4.7 introduced explicit variance annotations for generic type parameters: out for covariant positions and in for contravariant positions. Before these existed, TypeScript inferred variance by analyzing how each type parameter was used. The annotations make your intent explicit, catch mistakes earlier, and can improve type-checking performance on complex generic types.
What Is Variance?
Variance describes how the subtype relationship of a generic type relates to the subtype relationship of its type parameter.
If Dog extends Animal, then:
- Covariant (
out):Container<Dog>is assignable toContainer<Animal>. The subtype relationship is preserved. - Contravariant (
in):Handler<Animal>is assignable toHandler<Dog>. The subtype relationship is reversed. - Invariant: Neither direction works. The types are unrelated unless the parameter is exactly the same.
Covariance: Output Positions
A type parameter is covariant when it only appears in output (return) positions.
interface Producer<out T> {
get(): T;
}
class AnimalProducer implements Producer<Animal> {
get(): Animal {
return { name: 'Generic animal' };
}
}
class DogProducer implements Producer<Dog> {
get(): Dog {
return { name: 'Rex', breed: 'Labrador' };
}
}
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Covariant: Producer<Dog> is assignable to Producer<Animal>
const producer: Producer<Animal> = new DogProducer(); // valid
This makes intuitive sense. If you expect something that produces animals, a producer of dogs works because every dog is an animal.
Contravariance: Input Positions
A type parameter is contravariant when it only appears in input (parameter) positions.
interface Consumer<in T> {
accept(value: T): void;
}
class AnimalConsumer implements Consumer<Animal> {
accept(value: Animal): void {
console.log(value.name);
}
}
class DogConsumer implements Consumer<Dog> {
accept(value: Dog): void {
console.log(value.name, value.breed);
}
}
// Contravariant: Consumer<Animal> is assignable to Consumer<Dog>
const consumer: Consumer<Dog> = new AnimalConsumer(); // valid
// const bad: Consumer<Dog> = new DogConsumer(); // trivially valid (same type)
// const wrong: Consumer<Animal> = new DogConsumer(); // would fail: Dog consumer
// // cannot handle all animals
If you need something that can consume dogs, a consumer of animals works because it can handle any animal, including dogs.
Invariance: Both Positions
When a type parameter appears in both input and output positions, it is invariant. Neither direction is safe.
interface Store<in out T> {
get(): T;
set(value: T): void;
}
// Store<Dog> is NOT assignable to Store<Animal>
// because set(Animal) would allow setting a Cat into a Dog store.
// Store<Animal> is NOT assignable to Store<Dog>
// because get() would return Animal when Dog is expected.
The in out annotation makes this invariance explicit.
Why Annotations Matter
Without annotations, TypeScript infers variance. But inference can be wrong when the type is complex, or it can be expensive to compute. Explicit annotations serve three purposes.
First, they document intent. A reader immediately knows how the type parameter flows.
// Clear intent: T is only produced, never consumed
interface ReadonlyList<out T> {
get(index: number): T;
readonly length: number;
[Symbol.iterator](): Iterator<T>;
}
Second, they catch mistakes. If you annotate a parameter as out but accidentally use it in an input position, TypeScript reports an error.
interface Broken<out T> {
get(): T;
set(value: T): void; // Error: T is used in an 'in' position
// but is marked as 'out'
}
Third, they improve performance. On deeply nested generic types, variance inference can be expensive. Annotations let the compiler skip the analysis.
Practical Example: Event System
// Events are produced (covariant)
interface EventSource<out T> {
subscribe(listener: (event: T) => void): () => void;
}
// Event handlers consume events (contravariant)
interface EventHandler<in T> {
handle(event: T): void;
}
interface ClickEvent {
x: number;
y: number;
}
interface MouseEvent extends ClickEvent {
button: 'left' | 'right' | 'middle';
}
// EventSource<MouseEvent> can be used where EventSource<ClickEvent> is expected
function attachToCanvas(source: EventSource<ClickEvent>): void {
source.subscribe((event) => {
console.log(event.x, event.y);
});
}
// This works because MouseEvent extends ClickEvent and EventSource is covariant
declare const mouseSource: EventSource<MouseEvent>;
attachToCanvas(mouseSource); // valid
Variance in Function Types
Function types have built-in variance rules. Parameters are contravariant and return types are covariant.
type Transform<in T, out U> = (input: T) => U;
// Transform<Animal, Dog> is assignable to Transform<Dog, Animal>?
// Let's check:
// - Parameter: Animal (wider) in contravariant position -> Dog (narrower) works
// - Return: Dog (narrower) in covariant position -> Animal (wider) works
// Yes, this is valid.
const specificTransform: Transform<Animal, Dog> = (animal) => ({
name: animal.name,
breed: 'Unknown',
});
const generalTransform: Transform<Dog, Animal> = specificTransform; // valid
Variance With Readonly Collections
Readonly collections are naturally covariant because they only produce values.
interface ReadonlyBox<out T> {
readonly value: T;
}
// ReadonlyBox<Dog> is assignable to ReadonlyBox<Animal>
const dogBox: ReadonlyBox<Dog> = { value: { name: 'Rex', breed: 'Lab' } };
const animalBox: ReadonlyBox<Animal> = dogBox; // valid
Mutable collections are invariant because they both produce and consume values.
interface MutableBox<in out T> {
value: T;
}
// MutableBox<Dog> is NOT assignable to MutableBox<Animal>
const mutableDogBox: MutableBox<Dog> = { value: { name: 'Rex', breed: 'Lab' } };
// const mutableAnimalBox: MutableBox<Animal> = mutableDogBox; // Error
This is why readonly is not just about preventing mutations. It changes the variance of your container types, making them more flexible in generic contexts.
Common Pitfalls
A frequent mistake is assuming arrays are covariant. In TypeScript, arrays are treated as covariant for practical reasons (a Dog[] can be assigned to Animal[]), but this is technically unsound because you could push a Cat into the Animal[] reference that points to a Dog[].
const dogs: Dog[] = [{ name: 'Rex', breed: 'Lab' }];
const animals: Animal[] = dogs; // TypeScript allows this (unsound)
animals.push({ name: 'Whiskers' }); // Pushes a non-Dog into dogs!
Using ReadonlyArray<T> avoids this problem because it is genuinely covariant.
const dogs: readonly Dog[] = [{ name: 'Rex', breed: 'Lab' }];
const animals: readonly Animal[] = dogs; // Sound: no push method
When to Add Variance Annotations
Add variance annotations when you are authoring library types that will be used by many consumers. They serve as documentation and prevent accidental changes that break variance. For application code with simple generics, the inferred variance is usually fine.
Add them when you notice slow type checking in files with complex generic types. The annotations let the compiler skip variance inference.
Add them when you want the compiler to enforce that a type parameter stays in its intended position as the codebase evolves.
Wrap Up
Variance annotations in and out make generic type relationships explicit. Use out for type parameters that only appear in output positions (covariant), in for input positions (contravariant), and in out for both (invariant). They document intent, catch misuse, and improve compiler performance. Prefer readonly collections to get covariant flexibility.
Related articles
- TypeScript TypeScript Generics: Advanced Patterns and Constraints
Master advanced TypeScript generics patterns including conditional constraints, recursive types, generic factories, and type-safe builder patterns with examples.
- TypeScript TypeScript Utility Types: The Complete Guide
A comprehensive guide to all built-in TypeScript utility types including Partial, Required, Pick, Omit, Record, Extract, Exclude, and more with practical examples.
- TypeScript The Builder Pattern in TypeScript With Type Safety
Implement the builder pattern in TypeScript with compile-time enforcement of required fields using generics and conditional types.
- TypeScript TypeScript Generics Deep Dive
Go beyond basic generics — constraints, conditional types, mapped types, infer, and real-world patterns for writing type-safe reusable code.