Understanding Covariance, Contravariance, and Bivariance in TypeScript
This article explains TypeScript's covariant, contravariant, bivariant, and invariant type relationships, why methods and function properties behave differently under strictFunctionTypes, how ESLint's @typescript-eslint/method-signature-style rule enforces function property signatures, and practical ways to achieve safer type definitions.
Origin
The monorepo project encountered the ESLint rule @typescript-eslint/method-signature-style while applying a rule set.
Rule Example
Not Recommended
interface T1 {
func(arg: string): number;
}
type T2 = {
func(arg: boolean): void;
};
interface T3 {
func(arg: number): void;
func(arg: string): void;
func(arg: boolean): void;
}Recommended
interface T1 {
func: (arg: string) => number;
}
type T2 = {
func: (arg: boolean) => void;
};
// this is equivalent to the overload
interface T3 {
func: ((arg: number) => void) &
((arg: string) => void) &
((arg: boolean) => void);
}Reason
The rule claims that enabling it provides better function type checking when used with TypeScript's strictFunctionTypes mode.
A method and a function property of the same type behave differently. Methods are always bivariant in their argument, while function properties are contravariant in their argument under strictFunctionTypes .
Many developers are confused by the terms contravariant and why methods and function properties are treated differently.
Variance
Assignability
declare const a: string;
const b: string = a;In TypeScript, assignability is based on structural typing (duck typing).
What are Covariance and Contravariance?
Assume Dog is a subtype of Animal. Dog can be assigned to Animal. How does this affect a List of each type?
Covariant preserves the subtype relationship (e.g., List<Dog> can be assigned to List<Animal>).
Covariant (↗) keeps the subtype ordering Subtype ≤ BaseType.
Contravariant (↘) reverses the ordering, allowing List<Animal> to be assigned to List<Dog>.
Bivariant allows both directions.
Invariant disallows any assignment between the two.
How Position Affects Variance
Return type → covariant
F1: () => Cat;
F2: () => Animal;Parameter type → usually contravariant
const f1 = (cat: Cat) => { cat.miao(); };
const f2 = (animal: Animal) => { console.log(animal.weight); };
// error: not all Animals have miao()
const f3: (animal: Animal) => void = f1; // error
const f4: (cat: Cat) => void = f2; // okFunction Properties vs Methods
Type Checking
interface Animal { name: string; }
interface Dog extends Animal { wang: () => void; }
interface Cat extends Animal { miao: () => void; }
interface Comparer<T> { compare(a: T, b: T): number; }
declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;
animalComparer = dogComparer; // Ok because of bivariance
dogComparer = animalComparer; // Ok interface Animal { name: string; }
interface Dog extends Animal { wang: () => void; }
interface Cat extends Animal { miao: () => void; }
interface Comparer<T> { compare: (a: T, b: T) => number; }
declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;
animalComparer = dogComparer; // Error
dogComparer = animalComparer; // OkWith strictFunctionTypes enabled, TypeScript treats method parameters as bivariant and function property parameters as contravariant.
JS Output Difference
Compiled JavaScript shows that methods are placed on the prototype, while function properties become instance fields.
class Greeter {
constructor() {
this.greet();
this.greet2();
this.greet3();
}
greet() { console.log('greet1', this); }
greet2 = () => { console.log('greet2', this); }
greet3 = function() { console.log('greet3', this); }
}
let bla = new Greeter();Array Variance
TypeScript's Array<T> defines methods that are both covariant (e.g., pop) and contravariant (e.g., push), making the whole type bivariant and unsafe for mutable operations.
const dogs: Array<Dog> = [];
const animals: Animal[] = dogs; // bivariant
animals.push(new Cat()); // unsafeSeparating covariant and contravariant parts yields safer types:
// Covariant array type
interface CoArray<T> {
pop: () => T | undefined;
join?: (separator?: string) => string;
// ... other read‑only methods
}
// Contravariant array type
interface ContraArray<T> {
push: (...items: T[]) => number;
indexOf: (searchElement: T, fromIndex?: number) => number;
}TypeScript ultimately keeps Array bivariant for compatibility.
Summary
Prefer readonly to keep types immutable.
Use function properties instead of methods when strict type checking is needed.
Separate covariant and contravariant members or make types immutable.
Avoid bivariant types where possible.
Exercise
interface StatefulComponent {
setState(state: "started" | "stopped"): void;
}
function moveToPausedState(component: { setState(state: "started" | "stopped" | "paused"): void }) {
component.setState("paused");
}
const ListComponent: StatefulComponent = {
setState(state) {
switch (state) {
case "started":
case "stopped":
console.log(state);
break;
default:
console.error(state);
break;
}
}
};
moveToPausedState(ListComponent);References
@typescript-eslint/method-signature-style: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/method-signature-style.md
Subtype ordering: https://zh.wikipedia.org/wiki/子类型序关系
PR on strictFunctionTypes: https://github.com/microsoft/TypeScript/pull/18654
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
