EthicsComputerCourses
Join training +91 90532 10052

Variance & Assignability

TypeScript · Lesson 22 of 42 Intermediate 7 min read Updated

Sooner or later you meet an error like "Type (p: Student) => void is not assignable to type (p: Person) => void", even though every student is a person. The rule behind it is called variance: how the relationship between two types (a student is a person) carries over to types built from them, such as arrays, functions and generic interfaces.

This lesson explains variance with plain examples, shows the two places where TypeScript deliberately allows unsafe code, and introduces the in and out annotations you will see in library type definitions.

Assignability in one minute

TypeScript compares types by their structure (see Object Types). A type A is assignable to type B if a value of A can be used everywhere a B is expected. Usually that means A has at least the properties of B. We then call A a subtype of B.

TypeScript
type Person = { name: string };
type Student = { name: string; rollNo: number };

const kabir: Student = { name: "Kabir", rollNo: 14 };
const someone: Person = kabir;       // fine: Student has everything Person needs
const back: Student = someone;       // error: Person may lack rollNo
Output
app.ts(6,7): error TS2741: Property 'rollNo' is missing in type 'Person' but required in type 'Student'.

Variance answers the next question: if Student is assignable to Person, what about Student[] and Person[], or (s: Student) => void and (p: Person) => void?

Covariance: producers keep the direction

A type that only gives out values of T keeps the same direction. If Student is assignable to Person, then a function that returns a student is assignable to a function that returns a person. This is covariance ("varies together").

TypeScriptRun it yourself
type Person = { name: string };
type Student = { name: string; rollNo: number };

type Fetch<T> = () => T;

const fetchTopper: Fetch<Student> = () => ({ name: "Ananya", rollNo: 3 });
const fetchAnyone: Fetch<Person> = fetchTopper; // covariant: allowed

console.log(fetchAnyone().name);

const toppers: readonly Student[] = [{ name: "Ananya", rollNo: 3 }];
const people: readonly Person[] = toppers;      // readonly arrays are covariant
console.log(people.length);
Output
Ananya
1

This is safe: whoever calls fetchAnyone expects a person and receives a student, which has everything a person has. Return types, readonly arrays and Promise<T> are all covariant.

Contravariance: consumers reverse the direction

A type that only takes in values of T reverses the direction. A function that can handle any person can safely stand in for a function that handles students, but not the other way round. This is contravariance.

TypeScript
type Person = { name: string };
type Student = { name: string; rollNo: number };

type Handler<T> = (value: T) => void;

const greetPerson: Handler<Person> = (p) => console.log(`Hello ${p.name}`);
const printRoll: Handler<Student> = (s) => console.log(s.rollNo.toFixed(0));

const forStudents: Handler<Student> = greetPerson; // fine: contravariant
const forPeople: Handler<Person> = printRoll;      // error
Output
app.ts(10,7): error TS2322: Type 'Handler<Student>' is not assignable to type 'Handler<Person>'.
  Property 'rollNo' is missing in type 'Person' but required in type 'Student'.

The error protects you. If forPeople were allowed, someone could call forPeople({ name: "Isha" }), and printRoll would read rollNo from an object that has none. This check comes from the strictFunctionTypes option, part of strict (see Strict tsconfig Options).

You use contravariance every day without noticing: an onClick prop that expects (e: MouseEvent) => void happily accepts a function written as (e: Event) => void, because a function that handles any event can handle a mouse event.

Invariance: when T goes both ways

If a type both reads and writes T, neither direction is safe, so the type is invariant: only the exact same T is assignable.

TypeScript
type Person = { name: string };
type Student = { name: string; rollNo: number };

type Box<T> = {
  get: () => T;
  set: (value: T) => void;
};

declare const studentBox: Box<Student>;
const personBox: Box<Person> = studentBox;
Output
app.ts(10,7): error TS2322: Type 'Box<Student>' is not assignable to type 'Box<Person>'.
  Types of property 'set' are incompatible.
    Type '(value: Student) => void' is not assignable to type '(value: Person) => void'.
      Types of parameters 'value' and 'value' are incompatible.
        Property 'rollNo' is missing in type 'Person' but required in type 'Student'.

Read the error from the bottom up: set is the consumer that breaks the assignment. This is typical. When you see a long variance error, find the property where a value flows in.

Two deliberate holes: mutable arrays and methods

TypeScript chooses convenience over perfect safety in two well-known places. You should know both.

Mutable arrays are treated as covariant. Student[] is assignable to Person[], even though a Person[] lets you push a plain person. The result is an array of "students" that contains a non-student:

TypeScriptRun it yourself
type Person = { name: string };
type Student = { name: string; rollNo: number };

const classA: Student[] = [{ name: "Meera", rollNo: 1 }];
const everyone: Person[] = classA; // allowed, although not fully safe
everyone.push({ name: "Visitor from Kochi" });

for (const s of classA) {
  console.log(s.name, s.rollNo?.toString() ?? "has no rollNo!");
}
Output
Meera 1
Visitor from Kochi has no rollNo!

The fix is to accept readonly Person[] in functions that only read the list. A readonly array cannot be pushed to, so its covariance is genuinely safe.

Method parameters are checked both ways (bivariance). A member written with method syntax, handle(value: T): void, is not checked contravariantly, even under strict. A member written as a property with a function type, handle: (value: T) => void, is:

TypeScript
type Person = { name: string };
type Student = { name: string; rollNo: number };

interface MethodStyle { handle(p: Person): void }
interface PropertyStyle { handle: (p: Person) => void }

const studentOnly = { handle: (s: Student) => console.log(s.rollNo) };

const a: MethodStyle = studentOnly;   // allowed (bivariant): unsafe
const b: PropertyStyle = studentOnly; // error (contravariant): safe
Output
app.ts(10,7): error TS2322: Type '{ handle: (s: Student) => void; }' is not assignable to type 'PropertyStyle'.
  Types of property 'handle' are incompatible.
    Type '(s: Student) => void' is not assignable to type '(p: Person) => void'.
      Types of parameters 's' and 'p' are incompatible.
        Property 'rollNo' is missing in type 'Person' but required in type 'Student'.

Method bivariance exists because many built-in types, including arrays and DOM interfaces, would otherwise be unusable. For your own interfaces that hold callbacks, prefer the property style so the compiler can protect you.

The in and out annotations

Since TypeScript 4.7 you can state the intended variance of a type parameter: out T for covariant (T only comes out), in T for contravariant (T only goes in), and in out T for invariant. TypeScript checks the annotation against how T is actually used:

TypeScript
interface Producer<out T> {
  next: () => T;
}

interface Logger<out T> {
  write: (entry: T) => void;
}
Output
app.ts(5,18): error TS2636: Type 'Logger<sub-T>' is not assignable to type 'Logger<super-T>' as implied by variance annotation.
  Types of property 'write' are incompatible.
    Type '(entry: sub-T) => void' is not assignable to type '(entry: super-T) => void'.
      Types of parameters 'entry' and 'entry' are incompatible.
        Type 'super-T' is not assignable to type 'sub-T'.

Logger takes entries in, so it should be in T. You rarely need these annotations in application code, because TypeScript works out variance by itself. They are useful in libraries with very large, recursive generic types, where they document intent and can save the compiler work. When you see in or out in a .d.ts file, you now know what they mean.

Common mistakes

  • Fixing a variance error with as. The error usually describes a real call that would crash. Change the function's parameter type instead.
  • Accepting Person[] when you only read the list. Use readonly Person[]; callers can then pass Student[] safely.
  • Writing callback members with method syntax. Use onSave: (order: Order) => void so parameter checks are strict.
  • Assuming "subtype" means "declared with extends". Structure decides; extends is only one way to get a subtype.

Key points

  • Variance describes how "A is assignable to B" carries over to types built from A and B.
  • Producers (return types, readonly arrays, Promise<T>) are covariant; consumers (parameters) are contravariant; types that read and write T are invariant.
  • strictFunctionTypes enforces contravariant parameters for function-typed properties, but not for method syntax.
  • Mutable arrays are treated as covariant for convenience; accept readonly T[] when you only read.
  • out T, in T and in out T annotate intended variance, and TypeScript checks them.

Exercise

A school app has type Person = { name: string } and type Teacher = { name: string; subject: string }. Write a Notifier<T> type with a single send member that takes a T, written in property style. Create a Notifier<Person> that prints "Notice for <name>", and show that it can be assigned to a Notifier<Teacher> variable. Then explain in one comment line why the opposite assignment is rejected.

Show solution
TypeScript
type Person = { name: string };
type Teacher = { name: string; subject: string };

type Notifier<T> = { send: (to: T) => void };

const notifyAnyone: Notifier<Person> = {
  send: (p) => console.log(`Notice for ${p.name}`),
};

const notifyTeachers: Notifier<Teacher> = notifyAnyone; // contravariant: allowed
notifyTeachers.send({ name: "Mrs. Iyer", subject: "Physics" }); // Notice for Mrs. Iyer

// A Notifier<Teacher> may read p.subject, which a plain Person does not have.

Notifier only consumes T, so it is contravariant: a notifier for every person can serve teachers, but not the reverse.

Quick Quiz

1. Student is assignable to Person. Which assignment is allowed under strict?

2. Which of these is covariant in T?

3. Why is Student[] to Person[] not perfectly safe?

4. What does interface Sink<in T> declare?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call