+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Part 82 of 355

πŸ”„ Variance in TypeScript: Covariance and Contravariance

Master TypeScript's variance system including covariance, contravariance, and bivariance to understand how type relationships work in complex scenarios πŸš€

πŸ’ŽAdvanced
30 min read

Prerequisites

  • Deep understanding of TypeScript generics and constraints πŸ“
  • Experience with function types and subtyping ⚑
  • Knowledge of TypeScript's strict function types setting πŸ’»

What you'll learn

  • Understand covariance, contravariance, and bivariance concepts 🎯
  • Master function parameter and return type variance rules πŸ—οΈ
  • Apply variance principles to generic type design πŸ›
  • Debug complex assignability and subtyping issues ✨

🎯 Introduction

Welcome to the fascinating world of TypeScript variance! πŸŽ‰ This advanced tutorial explores one of the most sophisticated aspects of TypeScript’s type system: how types relate to each other through covariance, contravariance, and bivariance.

You’ll discover how variance affects type assignability, function compatibility, and generic type relationships. Understanding variance is crucial for building robust type-safe APIs, designing flexible generic utilities, and debugging complex type errors. Whether you’re building libraries πŸ“š, working with complex inheritance hierarchies πŸ—οΈ, or trying to understand why certain assignments work or fail ❌, mastering variance will elevate your TypeScript expertise.

By the end of this tutorial, you’ll think about type relationships like a compiler engineer! Let’s dive into the deep end! πŸŠβ€β™‚οΈ

πŸ“š Understanding Variance Fundamentals

πŸ€” What is Variance?

Variance describes how type relationships change when those types are used within more complex type constructs πŸ”„. Think of variance as the rules that govern whether you can substitute one type for another when they’re wrapped in generics, functions, or other type constructors.

In TypeScript terms, variance determines:

  • ✨ Whether Array<Dog> can be assigned to Array<Animal>
  • πŸš€ How function parameter types affect assignability
  • πŸ›‘οΈ Why some generic relationships work while others don’t
  • πŸ”„ When TypeScript allows or prevents certain substitutions

πŸ’‘ The Three Types of Variance

Here’s how different variance rules work:

  1. Covariance πŸ“ˆ: Preserves the direction of type relationships
  2. Contravariance πŸ“‰: Reverses the direction of type relationships
  3. Bivariance ↔️: Accepts both covariant and contravariant relationships
  4. Invariance πŸ”’: Requires exact type matches (no variance)

Real-world analogy: Think of variance like lanes on a highway πŸ›£οΈ. Covariance is like a one-way street going forward, contravariance is like a one-way street going backward, bivariance allows traffic in both directions, and invariance is like a closed road!

πŸ“ˆ Covariance: Preserving Type Order

🎯 Covariance in Return Types

Return types are covariant - if Dog extends Animal, then functions returning Dog can be assigned to functions expecting to return Animal:

// πŸ• Type hierarchy
interface Animal {
  name: string;
  age: number;
}

interface Dog extends Animal {
  breed: string;
  bark(): void;
}

interface Cat extends Animal {
  meow(): void;
  purr(): void;
}

// 🎯 Covariant return types
type AnimalProvider = () => Animal;
type DogProvider = () => Dog;

// βœ… Covariance: Dog provider can be assigned to Animal provider
const animalProvider: AnimalProvider = getDog; // βœ… Works!

function getDog(): Dog {
  return {
    name: "Buddy",
    age: 3,
    breed: "Golden Retriever",
    bark: () => console.log("Woof! πŸ•")
  };
}

function getAnimal(): Animal {
  return {
    name: "Generic Animal",
    age: 2
  };
}

// 🎨 Real-world example: API response handling
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

type UserResponse = ApiResponse<Dog>;
type GenericResponse = ApiResponse<Animal>;

// βœ… Covariance allows this assignment
const handleGenericResponse = (response: GenericResponse): void => {
  console.log(`Status: ${response.status}, Animal: ${response.data.name}`);
};

const dogResponse: UserResponse = {
  data: getDog(),
  status: 200,
  message: "Success"
};

handleGenericResponse(dogResponse); // βœ… Works due to covariance!

πŸ—οΈ Arrays and Covariance

Arrays are covariant in their element types (but with caveats):

// 🎯 Array covariance example
const dogs: Dog[] = [getDog()];
const animals: Animal[] = dogs; // βœ… Covariant assignment works

// ⚠️ However, this can lead to runtime issues!
animals.push({
  name: "Whiskers",
  age: 2,
  meow: () => console.log("Meow! 🐱"),
  purr: () => console.log("Purr... 😸")
} as Cat); // Adding a cat to what's really a Dog array!

// πŸ’₯ Runtime error when trying to access dog-specific methods
// dogs[1].bark(); // TypeError: dogs[1].bark is not a function

// πŸ›‘οΈ Safe covariance with readonly arrays
const readonlyDogs: readonly Dog[] = [getDog()];
const readonlyAnimals: readonly Animal[] = readonlyDogs; // βœ… Safe!

// ❌ Can't mutate, so no runtime issues
// readonlyAnimals.push(someCat); // Error: Property 'push' does not exist

πŸ“‰ Contravariance: Reversing Type Order

🎯 Contravariance in Function Parameters

Function parameters are contravariant - if Dog extends Animal, then functions accepting Animal can be assigned to positions expecting functions that accept Dog:

// 🎯 Contravariant function parameters
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;

// πŸ”„ Contravariance: Animal handler can be assigned to Dog handler position
const dogHandler: DogHandler = handleAnyAnimal; // βœ… Works!

function handleAnyAnimal(animal: Animal): void {
  console.log(`Handling ${animal.name}, age ${animal.age} 🐾`);
}

function handleSpecificDog(dog: Dog): void {
  console.log(`Handling ${dog.breed} named ${dog.name} πŸ•`);
  dog.bark();
}

// 🎨 Real-world example: Event handlers
interface ClickEvent {
  target: HTMLElement;
  clientX: number;
  clientY: number;
}

interface ButtonClickEvent extends ClickEvent {
  buttonType: 'primary' | 'secondary';
  disabled: boolean;
}

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

// βœ… Contravariance allows general event handler for specific event
const generalClickHandler: EventHandler<ClickEvent> = (event) => {
  console.log(`Click at (${event.clientX}, ${event.clientY}) πŸ‘†`);
};

const buttonClickHandler: EventHandler<ButtonClickEvent> = generalClickHandler; // βœ… Works!

// 🎯 Why contravariance makes sense:
// If you need a function that handles ButtonClickEvent,
// a function that handles any ClickEvent will work fine!
buttonClickHandler({
  target: document.createElement('button'),
  clientX: 100,
  clientY: 200,
  buttonType: 'primary',
  disabled: false
});

🧠 Understanding Contravariance Logic

// 🎭 The Substitution Principle explained
interface Veterinarian {
  treat(animal: Animal): void;
}

interface DogVeterinarian {
  treat(dog: Dog): void;
}

// πŸ€” Question: Can a general Veterinarian work as a DogVeterinarian?
// 🎯 Answer: YES! Because they can treat any animal, including dogs.

const generalVet: Veterinarian = {
  treat(animal: Animal) {
    console.log(`Treating ${animal.name} with general care πŸ₯`);
  }
};

const dogVet: DogVeterinarian = generalVet; // βœ… Contravariance at work!

// πŸ”„ But the reverse doesn't work:
const specialistVet: DogVeterinarian = {
  treat(dog: Dog) {
    console.log(`Specialized treatment for ${dog.breed} πŸ•β€βš•οΈ`);
    dog.bark(); // Using dog-specific methods
  }
};

// ❌ This would be unsafe!
// const generalVet2: Veterinarian = specialistVet; // Error with strict function types

// πŸ’‘ Why? Because what if we pass a Cat to generalVet2.treat()?
// The specialist only knows how to handle dogs!

↔️ Bivariance: TypeScript’s Default Behavior

🎯 Method Bivariance (Pre-strictFunctionTypes)

By default, TypeScript allows bivariance for methods to maintain compatibility:

// 🎨 Method bivariance example
interface AnimalTrainer {
  train(animal: Animal): void;
}

interface DogTrainer {
  train(dog: Dog): void;
}

// πŸ”„ With bivariance, both directions work for methods:
const animalTrainer: AnimalTrainer = {
  train(animal: Animal) {
    console.log(`Training ${animal.name} with basic commands πŸ“š`);
  }
};

const dogTrainer: DogTrainer = {
  train(dog: Dog) {
    console.log(`Training ${dog.breed} with advanced tricks πŸŽͺ`);
    dog.bark();
  }
};

// βœ… Bivariance allows both assignments (without strictFunctionTypes)
let trainer1: AnimalTrainer = dogTrainer;     // βœ… Contravariance direction
let trainer2: DogTrainer = animalTrainer;     // βœ… Covariance direction

// ⚠️ But this can be unsafe!
const cat: Cat = {
  name: "Whiskers",
  age: 3,
  meow: () => console.log("Meow! 🐱"),
  purr: () => console.log("Purr... 😸")
};

// πŸ’₯ Potential runtime error:
// trainer2.train(cat); // trainer2 expects a Dog but gets a Cat!

πŸ›‘οΈ Strict Function Types to the Rescue

// 🎯 With strictFunctionTypes: true, function parameters are contravariant

// βœ… This still works (contravariance)
const safeTrainer: DogTrainer = animalTrainer;

// ❌ This is now prevented (no unsafe covariance)
// const unsafeTrainer: AnimalTrainer = dogTrainer; // Error!

// 🎨 Exception: Methods in interfaces still allow bivariance
// This is for practical compatibility with common patterns

interface EventEmitter<T> {
  on(event: string, listener: (data: T) => void): void;
  emit(event: string, data: T): void;
}

// βœ… Still works due to method bivariance
const dogEmitter: EventEmitter<Dog> = {
  on: (event, listener) => { /* implementation */ },
  emit: (event, data) => { /* implementation */ }
};

const animalEmitter: EventEmitter<Animal> = dogEmitter; // βœ… Allowed

πŸ”„ Variance in Generic Types

🎯 Creating Variance-Aware Generic Types

// 🎯 Explicit variance annotations (TypeScript 4.7+)
interface Producer<out T> {
  produce(): T;
}

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

interface Processor<in TInput, out TOutput> {
  process(input: TInput): TOutput;
}

// βœ… Covariance: Producer<Dog> β†’ Producer<Animal>
const animalProducer: Producer<Animal> = {} as Producer<Dog>;

// βœ… Contravariance: Consumer<Animal> β†’ Consumer<Dog>  
const dogConsumer: Consumer<Dog> = {} as Consumer<Animal>;

// βœ… Mixed variance in Processor
const processor: Processor<Animal, Dog> = {} as Processor<Dog, Animal>;
//                        ↑in     ↑out              ↑in    ↑out
//                   contravariant covariant   contravariant covariant

// 🎨 Real-world example: Repository pattern
interface Repository<in TEntity, out TResult> {
  save(entity: TEntity): TResult;
  findById(id: string): TResult;
}

interface User {
  id: string;
  name: string;
  email: string;
}

interface AdminUser extends User {
  permissions: string[];
  accessLevel: number;
}

// βœ… Variance allows flexible repository assignment
const userRepo: Repository<User, User> = {} as Repository<AdminUser, AdminUser>;
//                        ↑contravariant    ↑covariant

🧩 Complex Variance Scenarios

// 🎯 Nested variance challenges
type EventCallback<T> = (event: T) => void;
type EventMap<T> = Record<string, EventCallback<T>>;

// πŸ€” What's the variance of EventMap<T>?
// EventCallback<T> is contravariant in T
// Record<K, V> is covariant in V
// So EventMap<T> is contravariant in T!

const dogEventMap: EventMap<Dog> = {
  'bark': (dog) => console.log(`${dog.name} barked! πŸ•`),
  'fetch': (dog) => console.log(`${dog.breed} is fetching! 🎾`)
};

const animalEventMap: EventMap<Animal> = dogEventMap; // βœ… Contravariance!

// 🎨 Function composition and variance
type Transform<TInput, TOutput> = (input: TInput) => TOutput;
type Compose<T1, T2, T3> = {
  first: Transform<T1, T2>;
  second: Transform<T2, T3>;
  compose(): Transform<T1, T3>;
};

// πŸ”„ Composition maintains variance rules
const stringToNumber: Transform<string, number> = (s) => s.length;
const numberToBoolean: Transform<number, boolean> = (n) => n> 0;

const composed: Compose<string, number, boolean> = {
  first: stringToNumber,
  second: numberToBoolean,
  compose() {
    return (input: string) => this.second(this.first(input));
  }
};

// βœ… Variance allows substitution in composition
interface LongString extends String {
  minLength: number;
}

// Contravariant in input, covariant in output
const flexibleTransform: Transform<string, boolean> = composed.compose();
const specificTransform: Transform<LongString, boolean> = flexibleTransform; // βœ… Works!

⚠️ Common Variance Pitfalls and Solutions

😱 Pitfall 1: Array Mutation with Covariance

// ❌ Dangerous: Array covariance can break type safety
function addAnimalToList(animals: Animal[]): void {
  animals.push({
    name: "Mystery Animal",
    age: 1
  });
}

const dogs: Dog[] = [getDog()];
addAnimalToList(dogs); // βœ… Compiles due to covariance

// πŸ’₯ Runtime issue: dogs array now contains a non-Dog!
// dogs.forEach(dog => dog.bark()); // Error: some animals don't have bark()

// βœ… Solution 1: Use readonly arrays for safe covariance
function processAnimals(animals: readonly Animal[]): Animal[] {
  return animals.map(animal => ({
    ...animal,
    name: `Processed ${animal.name}`
  }));
}

const processedDogs = processAnimals(dogs); // βœ… Safe!

// βœ… Solution 2: Use generic constraints
function processTypedAnimals<T extends Animal>(animals: T[]): T[] {
  return animals.map(animal => ({
    ...animal,
    name: `Processed ${animal.name}`
  } as T));
}

const processedTypedDogs = processTypedAnimals(dogs); // βœ… Preserves Dog type!

🀯 Pitfall 2: Callback Parameter Variance Confusion

// ❌ Common mistake: Thinking callback parameters are covariant
interface DataProcessor<T> {
  process(data: T[], callback: (item: T) => void): void;
}

// πŸ€” Incorrect assumption:
// "If Dog extends Animal, then DataProcessor<Dog> should extend DataProcessor<Animal>"

const animalProcessor: DataProcessor<Animal> = {
  process(data, callback) {
    data.forEach(callback);
  }
};

// ❌ This would be type-unsafe!
// const dogProcessor: DataProcessor<Dog> = animalProcessor; // Rightfully errors!

// πŸ’‘ Why it's unsafe:
const badDogProcessor = animalProcessor as DataProcessor<Dog>;
const dogs = [getDog()];
badDogProcessor.process(dogs, (dog) => {
  dog.bark(); // What if animalProcessor passes a Cat to this callback?
});

// βœ… Solution: Use proper variance-aware design
interface SafeDataProcessor<in T> {
  process(data: T[], callback: (item: T) => void): void;
}

// Now contravariance works as expected:
const safeDogProcessor: SafeDataProcessor<Dog> = animalProcessor; // βœ… Safe!

πŸ”„ Pitfall 3: Bivariance in Event Handlers

// ⚠️ Event handler bivariance can cause issues
interface EventSystem {
  addEventListener(type: string, handler: (event: Event) => void): void;
}

interface CustomEvent extends Event {
  customData: string;
}

const eventSystem: EventSystem = {
  addEventListener(type, handler) {
    // Implementation that might pass different event types
    document.addEventListener(type, handler);
  }
};

// πŸ€” Due to method bivariance, this compiles but could be unsafe:
eventSystem.addEventListener('custom', (event: CustomEvent) => {
  console.log(event.customData); // Might be undefined!
});

// βœ… Solution: Use function properties instead of methods
interface SafeEventSystem {
  addEventListener: (type: string, handler: (event: Event) => void) => void;
}

const safeEventSystem: SafeEventSystem = {
  addEventListener: (type, handler) => {
    document.addEventListener(type, handler);
  }
};

// ❌ Now properly prevents unsafe assignments
// safeEventSystem.addEventListener('custom', (event: CustomEvent) => {
//   console.log(event.customData); // Error: Type mismatch!
// });

πŸ› οΈ Best Practices for Variance

  1. 🎯 Use Strict Function Types: Enable strictFunctionTypes for safer parameter variance
  2. πŸ“ Prefer Readonly for Covariance: Use readonly arrays and objects when you need safe covariance
  3. πŸ—ΊοΈ Design with Variance in Mind: Consider how your generic types will be used in practice
  4. 🎨 Use Explicit Variance Annotations: When available, use in and out annotations for clarity
  5. ✨ Test Edge Cases: Verify your variance assumptions with concrete type tests
  6. πŸ”„ Avoid Bivariance: Use function properties instead of methods for stricter checking
  7. πŸ›‘οΈ Consider Invariance: Sometimes exact type matching is safer than flexible variance
  8. πŸ’‘ Document Variance Behavior: Help other developers understand your type design choices

πŸ§ͺ Hands-On Exercise

🎯 Challenge: Build a Variance-Aware Cache System

Create a type-safe caching system that properly handles variance:

πŸ“‹ Requirements:

  • ✨ Support covariant data retrieval (more specific types can be returned)
  • πŸ”„ Support contravariant key handling (more general keys can be accepted)
  • πŸ›‘οΈ Prevent unsafe mutations while allowing safe reads
  • 🎯 Use proper variance annotations where possible

πŸš€ Bonus Points:

  • Add expiration handling with variance-safe callbacks
  • Implement cache invalidation with proper type relationships
  • Create a cache hierarchy that respects inheritance

πŸ’‘ Solution

πŸ” Click to see solution
// 🎯 Variance-aware cache system

// ✨ Base interfaces with explicit variance
interface CacheEntry<out T> {
  readonly data: T;
  readonly timestamp: number;
  readonly ttl: number;
}

interface CacheReader<in TKey, out TValue> {
  get(key: TKey): CacheEntry<TValue> | undefined;
  has(key: TKey): boolean;
}

interface CacheWriter<in TKey, in TValue> {
  set(key: TKey, value: TValue, ttl?: number): void;
  delete(key: TKey): boolean;
  clear(): void;
}

interface Cache<TKey, TValue> extends CacheReader<TKey, TValue>, CacheWriter<TKey, TValue> {
  size: number;
  invalidate(predicate: (entry: CacheEntry<TValue>) => boolean): void;
}

// 🎨 Concrete implementation
class TypedCache<TKey, TValue> implements Cache<TKey, TValue> {
  private store = new Map<TKey, CacheEntry<TValue>>();

  get(key: TKey): CacheEntry<TValue> | undefined {
    const entry = this.store.get(key);
    if (entry && Date.now() - entry.timestamp> entry.ttl) {
      this.store.delete(key);
      return undefined;
    }
    return entry;
  }

  has(key: TKey): boolean {
    return this.get(key) !== undefined;
  }

  set(key: TKey, value: TValue, ttl: number = 60000): void {
    this.store.set(key, {
      data: value,
      timestamp: Date.now(),
      ttl
    });
  }

  delete(key: TKey): boolean {
    return this.store.delete(key);
  }

  clear(): void {
    this.store.clear();
  }

  get size(): number {
    return this.store.size;
  }

  invalidate(predicate: (entry: CacheEntry<TValue>) => boolean): void {
    for (const [key, entry] of this.store.entries()) {
      if (predicate(entry)) {
        this.store.delete(key);
      }
    }
  }
}

// πŸ§ͺ Variance testing
interface BaseUser {
  id: string;
  name: string;
}

interface AdminUser extends BaseUser {
  permissions: string[];
  accessLevel: number;
}

// βœ… Covariance: AdminUser cache can be read as BaseUser cache
const adminCache: CacheReader<string, AdminUser> = new TypedCache<string, AdminUser>();
const userReader: CacheReader<string, BaseUser> = adminCache; // βœ… Covariant in TValue

// βœ… Contravariance: BaseUser writer can write AdminUsers
const baseWriter: CacheWriter<string, BaseUser> = new TypedCache<string, BaseUser>();
const adminWriter: CacheWriter<string, AdminUser> = baseWriter; // βœ… Contravariant in TValue

// 🎯 Practical usage
const userCache = new TypedCache<string, BaseUser>();
const adminOnlyCache = new TypedCache<string, AdminUser>();

// βœ… Safe operations due to proper variance
function cacheUser(cache: CacheWriter<string, BaseUser>, user: BaseUser): void {
  cache.set(user.id, user);
}

function readUser(cache: CacheReader<string, BaseUser>): BaseUser | undefined {
  return cache.get("user123")?.data;
}

// Both caches work with the functions due to variance!
cacheUser(userCache, { id: "1", name: "John" });
cacheUser(adminOnlyCache, { id: "2", name: "Admin", permissions: ["read"], accessLevel: 5 });

const user1 = readUser(userCache);
const user2 = readUser(adminOnlyCache); // AdminUser treated as BaseUser safely

console.log("πŸŽ‰ Variance-aware cache system working perfectly!");

πŸŽ“ Key Takeaways

You’ve mastered TypeScript variance! Here’s what you now understand:

  • βœ… Covariance preserves type relationships in β€œoutput” positions πŸ’ͺ
  • βœ… Contravariance reverses type relationships in β€œinput” positions πŸ›‘οΈ
  • βœ… Function parameter variance and why it’s contravariant 🎯
  • βœ… Return type variance and why it’s covariant πŸ›
  • βœ… Array and generic variance behavior and safety implications πŸš€
  • βœ… Strict function types and how they improve type safety ✨
  • βœ… Bivariance pitfalls and when to avoid them πŸ”„

Remember: Variance is about substitutability - when can you safely use one type in place of another! 🀝

🀝 Next Steps

Congratulations! πŸŽ‰ You’ve mastered one of TypeScript’s most advanced concepts!

Here’s what to do next:

  1. πŸ’» Practice with complex generic designs using variance principles
  2. πŸ—οΈ Review your existing code for potential variance-related issues
  3. πŸ“š Move on to our next tutorial: Higher-Kinded Types - Advanced Type Patterns
  4. 🌟 Apply variance knowledge to library design and API creation
  5. πŸ” Explore TypeScript’s source code to see variance in action
  6. 🎯 Experiment with the upcoming variance annotations in newer TypeScript versions

Remember: Understanding variance gives you superpowers in type system design! Use them wisely. πŸš€


Happy variance mastering! πŸŽ‰πŸš€βœ¨