Skip to content
Codeloom
TypeScript

Variance Annotations (in/out) for TypeScript Generics

Understand covariance, contravariance, and how TypeScript's in and out annotations make generic type relationships explicit.

·6 min read · By Codeloom
Advanced 9 min read

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 to Container<Animal>. The subtype relationship is preserved.
  • Contravariant (in): Handler<Animal> is assignable to Handler<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.