Skip to content

TypeScript Variance — Complete Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about TypeScript Variance. We cover key concepts, practical examples, and best practices to help you master this topic.

TypeScript variance describes how subtyping relationships propagate through generic types — whether a List<Dog> is a subtype of List<Animal>, and why function parameters have the opposite direction from function return types.

What You'll Learn

  • Covariance: preserving subtype direction
  • Contravariance: reversing subtype direction
  • Invariance: no relationship preserved
  • Bivariance in function types (with strictFunctionTypes)

Why It Matters

Variance determines what assignments are type-safe. Getting variance wrong leads to runtime errors — like putting a Cat into a list of Dog because the list was typed as Animal[]. Understanding variance helps you design sound generic APIs.

Real-World Use

The Durga Antivirus Pro plugin system uses generic event emitters with proper variance. Event handlers are contravariant in their parameter types — a handler for Event can safely handle MouseEvent, but not vice versa. Getting this wrong would cause runtime crashes in the plugin system.

Learning Path

flowchart LR
  A[Recursive Types] --> B[Variance]
  B --> C[Overloads]
  B --> D[You Are Here]
  C --> E[Branded Types]
  E --> F[tsconfig Deep Dive]

What Is Variance?

Variance answers the question: if Dog extends Animal, what is the relationship between Wrapper<Dog> and Wrapper<Animal>?

class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }
class Cat extends Animal { color: string = ""; }

Covariance

Covariance preserves the subtype direction: if Dog is a subtype of Animal, then Wrapper<Dog> is a subtype of Wrapper<Animal>.

// Arrays are covariant in TypeScript (with caveats)
const dogs: Dog[] = [new Dog(), new Dog()];
const animals: Animal[] = dogs; // OK — covariant

// This compiles but causes runtime issues:
animals.push(new Cat()); // Valid at compile time, but dogs now has a Cat!

TypeScript arrays are covariant for practical reasons (most array operations are read-heavy), but this is unsound — you can push incompatible types.

Covariant Generics

interface Producer<T> {
  produce(): T;
}

class DogProducer implements Producer<Dog> {
  produce(): Dog { return new Dog(); }
}

const producer: Producer<Animal> = new DogProducer(); // OK — covariant
const animal = producer.produce(); // Returns Animal, actually Dog — safe

Covariance is safe for read-only (output) positions.

Contravariance

Contravariance reverses the subtype direction: if Dog is a subtype of Animal, then Consumer<Animal> is a subtype of Consumer<Dog>.

interface Consumer<T> {
  consume(value: T): void;
}

const animalConsumer: Consumer<Animal> = {
  consume(value: Animal) { console.log(value.name); },
};

const dogConsumer: Consumer<Dog> = animalConsumer; // OK — contravariant
dogConsumer.consume(new Dog()); // Works — Dog has .name

Why does this direction make sense? A consumer that accepts Animal can definitely handle Dog (because Dog is an Animal). But a consumer that accepts only Dog cannot handle any Animal.

Function Parameters Are Contravariant (with strictFunctionTypes)

declare let f1: (x: Animal) => void;
declare let f2: (x: Dog) => void;

f1 = f2; // Error with strictFunctionTypes — unsafe
// f2 accepts Dog only, but f1 could be called with Cat

f2 = f1; // OK — f1 accepts any Animal, Dog is an Animal

Invariance

Invariance means there is no subtype relationship: Wrapper<Dog> is neither a subtype nor a supertype of Wrapper<Animal>.

// Mutable containers should be invariant
interface Container<T> {
  get(): T;
  set(value: T): void;
}

const dogContainer: Container<Dog> = {
  _value: new Dog(),
  get() { return this._value; },
  set(v: Dog) { this._value = v; },
};

// const animalContainer: Container<Animal> = dogContainer; // Error — should be invariant
// animalContainer.set(new Cat()); // Would corrupt dogContainer

Bivariance

Bivariance allows both directions: Wrapper<Dog> is both a subtype and a supertype of Wrapper<Animal>. TypeScript's function types are bivariant by default (without strictFunctionTypes).

// Without strictFunctionTypes
let f1: (x: Animal) => void;
let f2: (x: Dog) => void;

f1 = f2; // OK — bivariant (unsafe)
f2 = f1; // OK — bivariant (safe)

strictFunctionTypes

Enable strictFunctionTypes: true (included in strict) to make function parameters contravariant (sound):

// With strictFunctionTypes: true
function processDogCallback(cb: (dog: Dog) => void): void {
  cb(new Dog());
}

const animalCallback = (animal: Animal) => {
  console.log(animal.name);
};

processDogCallback(animalCallback); // OK — parameter is contravariant
// animalCallback accepts Animal, Dog is Animal, so it's safe

Variance in Practice

Readonly/Immutable Types Are Covariant

interface ReadonlyList<T> {
  readonly [index: number]: T;
  length: number;
}

const dogs: ReadonlyList<Dog> = [new Dog()];
const animals: ReadonlyList<Animal> = dogs; // Safe — no mutation possible

Mutable Types Should Be Invariant

interface Array<T> {
  push(value: T): void;
  pop(): T | undefined;
  readonly length: number;
  [index: number]: T;
}
// Arrays are covariant in practice (for convenience), not invariant

Function Types Are Contravariant in Parameters

type EventHandler<T> = (event: T) => void;

const handleAnyEvent: EventHandler<Event> = (e) => console.log(e.type);
const handleClickEvent: EventHandler<MouseEvent> = handleAnyEvent; // OK

Common Mistakes

1. Relying on Array Covariance

const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs;
animals.push(new Cat()); // Compiles, but corrupts dogs array

Use readonly arrays (readonly Dog[] or ReadonlyArray<Dog>) for safe covariance.

2. Ignoring strictFunctionTypes

Without it, function parameters are bivariant, hiding real type errors.

3. Confusing Producer and Consumer Roles

// Producer — output position (covariant)
type Producer<T> = () => T;

// Consumer — input position (contravariant)
type Consumer<T> = (value: T) => void;

4. Making Everything Covariant

If a generic type has both input and output usage, it should be invariant (or split into read/write interfaces).

5. Not Using in and out Annotations

TypeScript 4.7+ supports variance annotations:

interface Producer<out T> { produce(): T; }       // Covariant
interface Consumer<in T> { consume(value: T): void; } // Contravariant

Practice Questions

  1. What is covariance? The subtype relationship is preserved: Wrapper<Dog> is a subtype of Wrapper<Animal> if Dog is a subtype of Animal.

  2. What is contravariance? The subtype relationship is reversed: Consumer<Animal> is a subtype of Consumer<Dog>.

  3. Why are function parameters contravariant? A function that accepts Animal can safely handle Dog (which is an Animal), making the more general handler usable in more specific positions.

  4. What does strictFunctionTypes do? It makes function parameter types contravariant (sound) instead of bivariant (unsafe by default).

Challenge: Create an EventEmitter<T> where T is the event type, the on(handler: (event: T) => void) method is contravariant in its parameter, and emit(event: T) is covariant. Verify that a handler for Event can be used where MouseEvent is expected.

FAQ

What is the relationship between variance and mutation?

Read-only positions (outputs) can be covariant. Write-only positions (inputs) can be contravariant. Read-write positions must be invariant.

Why did TypeScript make arrays covariant?

For practical convenience — most array usage is read-only. Making arrays invariant would break vast amounts of code.

Can I make my generic types invariant?

By default, TypeScript assumes invariance. Use in and out annotations (TS 4.7+) to explicitly declare covariance/contravariance.

What is the variance of `Promise`?

Promise<T> is covariant in T — it's a producer (you get a T out). Promise<Dog> is a Promise<Animal>.

Does variance affect performance?

No. Variance is a compile-time concept that affects Type Checking only. It has no runtime impact.

Mini Project: Type-Safe Event System with Variance

// src/event-system.ts

interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number; }
interface KeyboardEvent extends Event { key: string; ctrlKey: boolean; }

type EventHandler<T extends Event> = (event: T) => void;

class EventEmitter<T extends Event> {
  private handlers: EventHandler<T>[] = [];

  // Contravariant in handler parameter
  on(handler: EventHandler<T>): void {
    this.handlers.push(handler);
  }

  // Covariant in event value
  emit(event: T): void {
    for (const handler of this.handlers) {
      handler(event);
    }
  }

  off(handler: EventHandler<T>): void {
    this.handlers = this.handlers.filter(h => h !== handler);
  }
}

// Usage
const mouseEmitter = new EventEmitter<MouseEvent>();

// A handler for any Event can handle MouseEvent (contravariance)
const generalHandler: EventHandler<Event> = (event) => {
  console.log(`Event at ${event.timestamp}`);
};

// A handler specific to MouseEvent
const specificHandler: EventHandler<MouseEvent> = (event) => {
  console.log(`Mouse at (${event.x}, ${event.y})`);
};

mouseEmitter.on(generalHandler);  // OK — contravariant
mouseEmitter.on(specificHandler); // OK

mouseEmitter.emit({ timestamp: Date.now(), x: 100, y: 200 });
// Event at ...
// Mouse at (100, 200)

What's Next

Now explore function and constructor overloads:

Lesson Description
{{< ref "/programming-languages/typescript/27-recursive-types" >}} Review recursive types
{{< ref "/programming-languages/typescript/29-overloads-hybrid" >}} Function overloads, constructor overloads
{{< ref "/programming-languages/typescript/30-branded-types" >}} Branded types, nominal typing

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro