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 toArray<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:
- Covariance π: Preserves the direction of type relationships
- Contravariance π: Reverses the direction of type relationships
- Bivariance βοΈ: Accepts both covariant and contravariant relationships
- 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
- π― Use Strict Function Types: Enable
strictFunctionTypes
for safer parameter variance - π Prefer Readonly for Covariance: Use
readonly
arrays and objects when you need safe covariance - πΊοΈ Design with Variance in Mind: Consider how your generic types will be used in practice
- π¨ Use Explicit Variance Annotations: When available, use
in
andout
annotations for clarity - β¨ Test Edge Cases: Verify your variance assumptions with concrete type tests
- π Avoid Bivariance: Use function properties instead of methods for stricter checking
- π‘οΈ Consider Invariance: Sometimes exact type matching is safer than flexible variance
- π‘ 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:
- π» Practice with complex generic designs using variance principles
- ποΈ Review your existing code for potential variance-related issues
- π Move on to our next tutorial: Higher-Kinded Types - Advanced Type Patterns
- π Apply variance knowledge to library design and API creation
- π Explore TypeScriptβs source code to see variance in action
- π― 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! ππβ¨