Articles

TypeScript Variance Explained

Variance decides when TypeScript accepts Array<Dog> where Array<Animal> is expected — covariant, contravariant, or invariant, explained with examples.

Takina Takina · · 5 min read
A code editor showing TypeScript on a dark background

Variance describes the rule TypeScript uses to decide whether a generic type built from one type can substitute for the same generic type built from a related one — for example, whether an array of Dog can stand in for an array of Animal. There are three possible answers: covariant (yes, in the same direction as the underlying types), contravariant (yes, but only in the opposite direction), and invariant (no, the types must match exactly). Which answer applies depends on how the generic type uses its type parameter, not just on which generic type it is.

This isn’t an abstract type-theory curiosity. It’s the reason some type substitutions that look obviously safe are accepted by the compiler, while others that look just as reasonable are rejected — or worse, silently allowed in a spot where they can actually cause a runtime bug.

Covariance: the common case

A type is covariant in a parameter when it preserves the subtyping direction: if Dog is assignable to Animal, then Container<Dog> is assignable to Container<Animal>. This is the default, intuitive behavior for read-only positions.

interface Dog { bark(): void }
interface Animal { move(): void }

declare const dogs: ReadonlyArray<Dog>;
const animals: ReadonlyArray<Animal> = dogs; // fine — covariant

This works because a ReadonlyArray<Dog> only ever produces Dog values, and every Dog is usable wherever an Animal is expected. TypeScript’s structural type system checks this by comparing the shape of what the array’s methods return, not by name.

Regular, mutable arrays are treated the same way for assignability, which is actually where variance gets dangerous — see the section on arrays below.

Contravariance: functions run the other way

A type is contravariant in a parameter when the subtyping direction flips. The clearest example is a function’s parameter types:

type AnimalHandler = (a: Animal) => void;
type DogHandler = (d: Dog) => void;

declare const handleAnimal: AnimalHandler;
const handleDog: DogHandler = handleAnimal; // fine — contravariant

This is safe in the opposite direction from the array example: a function that can handle any Animal can certainly handle the more specific case of a Dog, so it’s fine to use an AnimalHandler wherever a DogHandler is expected. But the reverse — using a DogHandler (which might call dog.bark()) wherever an AnimalHandler is expected — isn’t safe, because it could be called with a Cat, which has no bark method. Function parameters are contravariant because the caller controls what gets passed in, and a function that promises to handle less specific input is strictly more flexible than one that only handles a narrow case.

Invariance: when neither direction is safe

A type is invariant in a parameter when substitution isn’t safe in either direction, because the type is used in a position that’s both read from and written to. This is exactly what happens with mutable arrays:

function addCat(animals: Animal[]) {
  animals.push({ move: () => {} } as Animal); // no bark()
}

const dogs: Dog[] = [{ bark: () => {}, move: () => {} }];
addCat(dogs); // TypeScript actually allows this — and it's unsound
dogs[0].bark(); // runtime error: the pushed object has no bark()

Mutable arrays should, strictly, be invariant — you can’t safely treat Dog[] as an Animal[] if the caller might write a plain Animal into it. TypeScript historically treats arrays and object properties as covariant anyway, for practical reasons: true invariance would reject a lot of everyday code that never actually triggers the unsound case. This is a deliberate, documented trade-off between soundness and ergonomics, not an oversight — but it’s worth knowing about, because it means the compiler won’t catch every type error that variance rules would, in principle, flag.

Variance at a glance

PositionVarianceExample
Return type / read-only propertyCovariant() => Dog assignable to () => Animal
Function parameterContravariant(a: Animal) => void assignable to (d: Dog) => void
Mutable property / array elementInvariant (in theory), covariant in practiceDog[] assignable to Animal[], unsoundly
Generic type parameter, general caseDepends on usage inside the typeDetermined structurally

Why this matters for generics you write yourself

When you design a generic interface, how you use the type parameter determines its variance automatically — TypeScript infers it from usage rather than requiring you to declare it up front. A generic type that only produces T values (getters, read-only collections) behaves covariantly. One that only consumes T values (setters, callback parameters) behaves contravariantly. One that does both — a typical mutable container with both get and set — is invariant in principle, though TypeScript’s array leniency means this isn’t always strictly enforced.

This matters most when you’re modeling something like an event system or a plugin architecture with generics: a Handler<T> type that only reads T can safely accept a handler for a more general type, while a Store<T> that both reads and writes T cannot, without risking exactly the kind of runtime error shown above. Getting this wrong doesn’t usually surface as a compiler error — because of the covariance leniency described above — so it tends to show up as a subtle bug instead, which is precisely why understanding the underlying rule is more useful than memorizing which specific cases the compiler happens to catch.

Variance interacts with other structural typing decisions covered in interfaces vs. types and with readonly modifiers, since marking a property readonly is one of the few ways to push a type toward safer covariant behavior instead of the unsound invariant case. It’s also part of why TypeScript’s structural system, described more generally in TypeScript vs. JavaScript, differs so much from nominal type systems, where variance rules tend to be enforced strictly rather than inferred and occasionally relaxed for practicality. Some of TypeScript’s built-in utility types, like Readonly<T>, exist partly to convert an invariant shape into a covariant one on demand.

The takeaway

Variance answers a narrow but important question: given that Dog is a subtype of Animal, is SomeGeneric<Dog> a subtype of SomeGeneric<Animal>? Covariant types (read-only producers) say yes in the natural direction, contravariant types (function parameters) say yes only in reverse, and invariant types (mutable containers) shouldn’t say yes at all — though TypeScript relaxes that last rule for arrays and object properties as a practical trade-off. Knowing which case you’re in explains both why some assignments the compiler accepts feel surprising, and why a small number of type-safe-looking programs can still fail at runtime.

Takina Takina · · 4 min read

Structural Typing vs Nominal Typing in TypeScript

Structural typing checks shape, not name — TypeScript treats two differently-named types as compatible if their members match, unlike nominal systems.

#TypeScript #JavaScript #Web Development
Takina Takina · · 5 min read

Zod vs Yup: TypeScript Schema Validation Compared

Zod infers static TypeScript types directly from its schemas; Yup was built for JavaScript form validation first. How the two approaches differ.

#TypeScript #JavaScript #Web Development
Takina Takina · · 5 min read

TypeScript Declaration Files (.d.ts) Explained

A .d.ts file describes a library's types without its implementation. How TypeScript declaration files work, and how to write one for an untyped JS package.

#TypeScript #JavaScript #Web Development