The TypeScript type system is founded on Structural Subtyping grounded in the Liskov Substitution Principle (LSP). If type S contains all members of type T with compatible signatures, then S is a subtype of T (formally denoted as S <: T). This guarantees that a value of type S can safely substitute for type T without breaking program correctness.
However, moving from scalar types to generic type constructors of the form F<T> introduces a fundamental question in type theory: how does the subtyping relationship between arguments A <: B translate into a subtyping relationship between the compound types F<A> and F<B>?
The answer lies in Variance Theory. The behavior of any type constructor is classified into four distinct categories:
out T): The subtyping relationship is preserved. If A <: B, then F<A> <: F<B>. This is characteristic of data producers (Producer / Output structures from which data is only read).in T): The subtyping relationship is inverted. If A <: B, then F<B> <: F<A>. This is characteristic of consumers (Consumer / Input structures where data is received as arguments).in out T): No subtyping relationship holds. F<A> <: F<B> holds if and only if A is identically equal to B. This occurs when a type parameter is used simultaneously in read and write positions (mutable containers).A <: B, then F<A> <: F<B> and F<B> <: F<A>. This is TypeScript's historical compromise for method parameters in classes and interfaces.Beginning in version 4.7, TypeScript introduced explicit variance annotations (in, out). These enable library authors to declare variance intent at the generic parameter definition site, short-circuiting expensive structural compiler checks and eliminating subtle compatibility bugs.
Consider the subtyping hierarchy: Admin <: User <: Entity (where Admin is the most specific subtype and Entity is the widest base supertype).
+-------------------------------------------------------------------------+
| SUBTYPING HIERARCHY |
| Admin <: User <: Entity |
+-------------------------------------------------------------------------+
| 1. COVARIANCE (out T) - Direction PRESERVED: |
| Producer<Admin> <: Producer<User> <: Producer<Entity> |
| (Safe: reading a User is guaranteed to yield all User properties) |
+-------------------------------------------------------------------------+
| 2. CONTRAVARIANCE (in T) - Direction INVERTED: |
| Consumer<Entity> <: Consumer<User> <: Consumer<Admin> |
| (Safe: an Entity handler is fully equipped to handle User and Admin) |
+-------------------------------------------------------------------------+
| 3. INVARIANCE (in out T) - Subtyping FORBIDDEN: |
| Box<Admin> != Box<User> != Box<Entity> |
| (Mutation safety: writing an Entity into Box<Admin> breaks types) |
+-------------------------------------------------------------------------+A type parameter's position in a signature dictates its variance:
Function return positions are strictly covariant (out).
Function argument positions are strictly contravariant (in) under the --strictFunctionTypes flag.
Mutable property positions (read and write) are strictly invariant.
EventHandler<in TEvent>, ensuring that a generic EventHandler<DomainEvent> logger can safely process specific UserCreatedEvent payloads.ReadonlyRepository<out TEntity> alongside invariant mutable stores MutableStore<in out TState>.in/out annotations in massive enterprise monorepos reduce compiler inference overhead by up to 40% on deeply nested generic types (e.g. in Zod, tRPC, TypeBox).Validator<in TInput, out TOutput> with isolated variance control across ingest and egress streams.The compiler inspects interface declarations, flagging usages of T: function arguments (Input) vs return types (Output).
Output-only positions yield Covariant. Input-only positions yield Contravariant. Both yield Invariant.
If an engineer declared out T but T appears in a function parameter, the compiler halts with TS1398.
When comparing F<A> against F<B>, the compiler skips deep member walks, relying directly on variance rules.
out T)#Covariance occurs when a type parameter appears exclusively as a data provider (Producer):
export interface Entity {
id: string;
}
export interface User extends Entity {
email: string;
}
export interface Admin extends User {
permissions: string[];
}
// Generic parameter marked with 'out': T is exclusively in an output position
export interface ReadonlyCollection<out T> {
getById(id: string): T | undefined;
getAll(): readonly T[];
}
declare let adminCollection: ReadonlyCollection<Admin>;
declare let userCollection: ReadonlyCollection<User>;
// COVARIANCE: Admin <: User => ReadonlyCollection<Admin> <: ReadonlyCollection<User>
// Completely safe: every item read from adminCollection satisfies the User contract!
userCollection = adminCollection; // Valid
const user = userCollection.getById("user-1");
if (user) {
console.log(user.email); // Fully valid access
}in T)#Contravariance occurs when a type parameter appears exclusively as a data receiver (Consumer). The direction of the subtyping relationship is inverted:
import { Entity, User, Admin } from './covariance';
// Generic parameter marked with 'in': T is exclusively in an input position
export interface Serializer<in T> {
serialize(data: T): string;
}
declare let entitySerializer: Serializer<Entity>;
declare let userSerializer: Serializer<User>;
declare let adminSerializer: Serializer<Admin>;
// CONTRAVARIANCE: Entity <: User => Serializer<Entity> <: Serializer<User>
// An Entity serializer knows how to serialize any Entity.
// Therefore, it can safely handle any User or Admin!
userSerializer = entitySerializer; // Valid!
// The reverse assignment is strictly forbidden and produces a type error:
// entitySerializer = userSerializer; // Error TS2322!
// userSerializer expects an email property, which a raw Entity does not possess.in out T)#When a type parameter is used simultaneously for reading and writing, subtyping coercion must be prohibited:
import { User, Admin } from './covariance';
export interface Storage<in out T> {
read(id: string): T;
write(item: T): void;
}
declare let adminStorage: Storage<Admin>;
declare let userStorage: Storage<User>;
// INVARIANCE: Assignment is rejected in both directions!
// userStorage = adminStorage; // Error TS2322!
// adminStorage = userStorage; // Error TS2322!
// WHY? Demonstrating runtime failure if invariance were ignored:
function unsafeDemonstration(store: Storage<User>, plainUser: User) {
// If 'userStorage = adminStorage' were permitted,
// we could write a regular User into an Admin storage container!
store.write(plainUser);
// Now adminStorage holds a plain User lacking the permissions property.
// Calling adminStorage.read().permissions would trigger a runtime TypeError!
}A critical nuance in TypeScript is the difference between method syntax and function property syntax under --strictFunctionTypes:
import { User, Admin } from './covariance';
// 1. Interface method: historically BIVARIANT
interface EventListenerMethod<T> {
handle(event: T): void;
}
// 2. Function property: strictly CONTRAVARIANT
interface EventListenerProperty<T> {
handle: (event: T) => void;
}
declare let adminMethodListener: EventListenerMethod<Admin>;
declare let userMethodListener: EventListenerMethod<User>;
declare let adminPropListener: EventListenerProperty<Admin>;
declare let userPropListener: EventListenerProperty<User>;
// Method bivariance permits dangerous assignments:
adminMethodListener = userMethodListener; // Valid (contravariant)
userMethodListener = adminMethodListener; // Valid! (BIVARIANT - potential runtime crash)
// Strict function property prevents dangerous assignments:
adminPropListener = userPropListener; // Valid (contravariant: User <: Admin)
// userPropListener = adminPropListener; // ERROR TS2322! Blocked by compiler.Below is an enterprise-grade EventBus implementation that prevents parameter mismatch bugs using contravariant consumers and covariant producers:
export interface DomainEvent {
readonly aggregateId: string;
readonly occurredAt: number;
}
// Contravariant event consumer
export interface Consumer<in TEvent extends DomainEvent> {
consume: (event: TEvent) => void | Promise<void>;
}
// Covariant event producer
export interface Producer<out TEvent extends DomainEvent> {
poll: () => readonly TEvent[];
}
export class TypeSafeEventBus<TBaseEvent extends DomainEvent> {
private handlers = new Map<string, Set<(e: unknown) => void>>();
public subscribe<TEvent extends TBaseEvent>(
eventName: string,
consumer: Consumer<TEvent>
): () => void {
let set = this.handlers.get(eventName);
if (!set) {
set = new Set();
this.handlers.set(eventName, set);
}
const handlerWrapper = (e: unknown) => consumer.consume(e as TEvent);
set.add(handlerWrapper);
return () => {
set?.delete(handlerWrapper);
};
}
public publish(eventName: string, event: TBaseEvent): void {
const set = this.handlers.get(eventName);
if (set) {
set.forEach(fn => fn(event));
}
}
}Declaring callbacks with method syntax method(arg: T): void enables method bivariance even with --strictFunctionTypes enabled. This allows handlers expecting a specific subtype to be assigned to sources emitting base types, resulting in undefined property errors at runtime.
// ❌ BAD: method bivariance masks type safety bugs
interface BadSink<T> {
accept(item: T): void;
}
// ✅ GOOD: function property guarantees strict contravariance
interface GoodSink<T> {
accept: (item: T) => void;
}In TypeScript, built-in arrays T[] are covariant on read but mutable on write. Assigning Dog[] to an Animal[] variable is permitted, but calling .push(cat) into the Animal[] reference corrupts the underlying Dog[] instance. Protect mutable collections with readonly T[].
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
const dogs: Dog[] = [{ name: "Rex", bark: () => {} }];
// ❌ DANGEROUS: mutable array coerced to supertype
const animals: Animal[] = dogs;
// animals.push({ name: "Cat" }); // At runtime, dogs[1].bark() throws TypeError!
// ✅ SAFE: protect with immutability (covariance without mutation risk)
const safeAnimals: readonly Animal[] = dogs;
// safeAnimals.push(...); // Compiler Error TS2339!If you specify out T but use T in a function parameter (input position), the TypeScript compiler halts with error TS1398. Annotations do not coerce variance; they validate design correctness.
// ❌ ERROR TS1398: Type parameter T was declared with 'out' variance, but is used in an input position
interface InvalidProducer<out T> {
set: (val: T) => void;
}
// ✅ CORRECT: parameter aligns with input position
interface ValidConsumer<in T> {
set: (val: T) => void;
}Writing T extends any is meaningless because any disables type checking. If T can be any type, omit the extends clause entirely, or use T extends unknown for safe narrowing.
Contravariance is checked: the expected parameter type must be a subtype of the actual received argument type.
Covariance is checked: the actual function return type must be a subtype of the declared result type.
For all writable properties, the compiler enforces two-way type equivalence (invariance).
If all three conditions are satisfied, assignment is deemed type-safe.