β€’

tech

Covariant, Contravariant, and Invariant in Typescript

Learn how variance works in Typescript with concrete example: Covariant, Contravariant, Invariant, and Bivariant. Learn also how variance is related to composition and assignability.


Sandro Maglione

Sandro Maglione

Software development

Variance in Typescript specifies how a generic type F<T> varies with respect to its type parameter T:

If T extends U, variance allows to know how F<T> and F<U> are related:

  • Covariant: F<T> extends F<U>
  • Contravariant: F<U> extends F<T>
  • Invariant: Neither covariant nor contravariant
  • Bivariant: F<T> extends F<U> and F<U> extends F<T>

Enough with abstract definitions. Let's see how this works in practice πŸ‘‡


How variance works in Typescript

We are going to understand variance in practice using the following types:

interface Animal {
  animalStuff: any;
}

interface Dog extends Animal {
  dogStuff: any;
}

type Getter<T> = () => T; // Covariant

type Setter<T> = (value: T) => void; // Contravariant

type Inv<T> = (value: T) => T; // Invariant

We use a function type with a generic parameter T.

In the example we have Animal and Dog, where Dog extends Animal. Variance allows to define the relation between Getter<Animal> and Getter<Dog> (same for Setter and Inv).

If Dog extends Animal, is it still true that Getter<Dog> extends Getter<Animal>?

Let's see. We are going to show how variance works with a practical example πŸ‘‡

Getter function

An example of Covariant is the getter function:

type Getter<T> = () => T; // Covariant

This function takes no parameters and returns a value of a generic type T.

Covariant

Covariant means that when Dog extends Animal then also Getter<Dog> extends Getter<Animal> applies.

We can prove this by implementing a Getter<Dog>:

const getDog: Getter<Dog> = () => ({ animalStuff: "", dogStuff: "" });

We then define a function that takes a Getter<Animal> as parameter:

const withGetAnimal = (get: Getter<Animal>) => get().animalStuff;

Can we call withGetAnimal with getDog as parameter? Since Getter is covariant, the answer is yes:

withGetAnimal((): Animal => {
  return getDog(); // () => Dog
}); // Covariant

withGetAnimal requires a function that returns an Animal (Getter<Animal>). Since getDog returns a Dog, which is an Animal since Dog extends Animal, then this works correctly.

Therefore since Dog extends Animal then also Getter<Dog> extends Getter<Animal> applies.

Contravariant

Does the opposite also apply?

If Dog extends Animal can we conclude that Getter<Animal> extends Getter<Dog>?

Notice how Animal and Dog are inverted compared to before.

Let's do the same as we did in the example above, with getAnimal instead of getDog:

const getAnimal: Getter<Animal> = () => ({ animalStuff: "" });
const withGetDog = (get: Getter<Dog>) => get().dogStuff;

Can we pass getAnimal to withGetDog? The answer is no:

withGetDog((): Dog => {
  // ⛔️ Property 'dogStuff' is missing in type 'Animal' but required in type 'Dog'
  return getAnimal(); // () => Animal
}); // No Contravariant

withGetDog requires a function that returns a Dog (Getter<Dog>). getAnimal returns an Animal, but Animal is not a Dog, since it is missing the dogStuff property:

interface Animal {
  animalStuff: any;
}

interface Dog extends Animal {
  dogStuff: any; // πŸ‘ˆ `Animal` is not `Dog`
}

Therefore Dog extends Animal does not imply Getter<Animal> extends Getter<Dog>.

There is more 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

Setter function

An example of Contravariant instead is the setter function:

type Setter<T> = (value: T) => void; // Contravariant

This function takes a single parameters of type T and returns void.

Covariant

Just as before, we define a Setter<Dog> function:

const setDog: Setter<Dog> = (dog) => {
  dog.dogStuff = 1;
};

And also a function that takes a Setter<Animal> as parameter:

const withSetAnimal = (set: Setter<Animal>) => set({ animalStuff: "" });

Can we pass setDog to withSetAnimal? The answer is no:

withSetAnimal((animal /** Animal */) => {
  // ⛔️ Argument of type 'Animal' is not assignable to parameter of type 'Dog'
  setDog(animal); // (value: Dog) => void
}); // Contravariant

withSetAnimal gives us an Animal as parameter. However, getDog requires a Dog. As we said previously, Animal is not a Dog, since it is missing the dogStuff property:

interface Animal {
  animalStuff: any;
}

interface Dog extends Animal {
  dogStuff: any; // πŸ‘ˆ `Animal` is not `Dog`
}

We cannot pass animal to setDog. Therefore Dog extends Animal does not imply Setter<Dog> extends Setter<Animal>, Setter is not covariant.

Contravariant

We do the same inverting Dog and Animal, defining a setAnimal function this time:

const withSetDog = (set: Setter<Dog>) => set({ animalStuff: "", dogStuff: "" });
const setAnimal: Setter<Animal> = (animal) => {
  animal.animalStuff = 1;
};

withSetDog accepts setAnimal? The answer is yes:

withSetDog((dog /** Dog */) => {
  setAnimal(dog); // (value: Animal) => void
}); // Contravariant

withSetDog gives us a Dog. Since setAnimal accepts an Animal, and since Dog is an Animal (Dog extends Animal), we can pass dog to setAnimal.

Setter is contravariant: Dog extends Animal implies that Setter<Animal> extends Setter<Dog>.

Invariance

An example of Invariance is the following:

type Inv<T> = (value: T) => T; // Invariant

A function that takes a parameter of type T and returns T.

Same as before we define a Inv<Dog>, withAnimalInv.

We can see that we cannot apply invDog to withAnimalInv:

const withAnimalInv = (inv: Inv<Animal>) => inv({ animalStuff: "" });
const invDog: Inv<Dog> = (dog) => ({ animalStuff: "", dogStuff: "" });

withAnimalInv((animal): Animal => {
  // ⛔️ Argument of type 'Animal' is not assignable to parameter of type 'Dog' (Input type!)
  return invDog(animal); // (value: Dog) => Dog
});

withAnimalInv provides an Animal and expects a return type of Animal. invDog indeed returns a Dog, which is a valid Animal. However, invDog also requires a Dog as parameter, so animal is not valid.

Therefore Inv<T> is not covariant.

We do the same for contravariance by defining invAnimal:

const withDogInv = (inv: Inv<Dog>) => inv({ animalStuff: "", dogStuff: "" });
const invAnimal: Inv<Animal> = (animal) => ({ animalStuff: "" });

withDogInv((dog): Dog => {
  // ⛔️ Type 'Animal' is not assignable to type 'Dog' (Return type!)
  return invAnimal(dog); // (value: Animal) => Animal
});

withDogInv provides a Dog. In this case we can pass dog to invAnimal since it requires an Animal. This time instead the error comes from the return type: withDogInv requires a Dog as return type, but invAnimal returns an Animal.

Therefore Inv<T> is not contravariant.

Since Inv<T> is not covariant and not contravariant, we say that Int<T> is invariant:

  • Dog extends Animal does not imply that Inv<Dog> extends Inv<Animal>
  • Dog extends Animal does not imply that Inv<Animal> extends Inv<Dog>

There is more 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

Variance and composition: Union and Intersection types

Variance is relevant also to understand composition in typescript:

Contravariant parameters composed as an intersection (&) are equivalent to covariant parameters composed together as a union (|) for purposes of assignability

Simply put, this principle relates <A>, <A | B>, and <A & B> when assigning variables:

  • If I have a = Type<A>, can I assign a to b: Type<A & B>?
  • If I have a = Type<A>, can I assign a to b: Type<A | B>?

This assignability depends on variance.

Enough theory, let's see a concrete example to understand why this matter πŸ‘‡

Union types

We are going to use Getter, Setter, and Inv again:

type Getter<T> = () => T; // Covariant

type Setter<T> = (value: T) => void; // Contravariant

type Inv<T> = (value: T) => T; // Invariant

Let's see an example for all of them.

const getterUnion = <A, B>(a: Getter<A>): Getter<A | B> => {
  /**
   * `a`: function that returns `A`
   * `b`: function that returns `A | B`
   *
   * It works since `A` is "included" in `A | B`
   */
  const b: Getter<A | B> = a;
  return b;
};

It is possible to assign a variable of type Getter<A> to a variable of type Getter<A | B>.

Getter is a function that returns the given type. A | B means "A or B". Since Getter<A> returns A, its value can be assigned to A | B.

const setterUnion = <A, B>(a: Setter<A>): Setter<A | B> => {
  /**
   * ⛔️ 'B' could be instantiated with an arbitrary type which could be unrelated to 'A'
   *
   * `b`: (_: A | B) => void
   */
  const b: Setter<A | B> = a;
  return b;
};

With Setter this assignability does not work. Setter requires a parameter of type A | B, but Setter<A> provides only A:

/**
 * `a` is of type `{ a: number }`, but we provided `{ b: string }`
 * so there is no way to actually have a valid `{ a: number }` for the setter πŸ€·πŸΌβ€β™‚οΈ
 */
setterUnion<{ a: number }, { b: string }>((a) => {})({ b: "" });

In the example above we provide a type of { b: string }, but Setter<A> expects a type of { a: number }, which is not present. Therefore assignability does not work.

const invUnion = <A, B>(a: Inv<A>): Inv<A | B> => {
  /** ⛔️ 'A' could be instantiated with an arbitrary type which could be unrelated to 'A | B' */
  const b: Inv<A | B> = a;
  return b;
};

Inv requires both input and output to be of type A | B, so it is definitely not possible to assign A.

Intersection types

We do the same analysis for intersection types (&):

const getterIntersection = <A, B>(a: Getter<A>): Getter<A & B> => {
  /**
   * ⛔️ 'B' could be instantiated with an arbitrary type which could be unrelated to 'A'
   *
   * `b` returns something that requires both `A` and `B`, but `a` provides only `A`
   */
  const b: Getter<A & B> = a;
  return b;
};

This time Getter<A> cannot be assigned to Getter<A & B>.

Getter<A & B> returns A & B, "A and B", but Getter<A> provides only A. No assignability then.

const setterIntersection = <A, B>(a: Setter<A>): Setter<A & B> => {
  /**
   * `a`: (_: A) => void
   * `b`: (_: A & B) => void
   *
   * Input parameters will be provided later
   * For now `A` is included in `A & B`, therefore this works
   */
  const b: Setter<A & B> = a;
  return b;
};

The inverse is valid also for Setter.

Setter requires a parameter of type A & B, "A and B". For Setter<A> instead a parameter of type A is enough. Since Setter<A & B> gets access to both A and B, A is available for Setter<A>, so this works:

/**
 * Calling the function requires both `{ a: number }` and `{ b: string }`.
 * 
 * We then pass `{ a: number }` to the first setter, this works βœ…
 */
setterIntersection<{ a: number }, { b: string }>((a) => {})({ b: "", a: 0 });

Finally Inv:

const invIntersection = <A, B>(a: Inv<A>): Inv<A & B> => {
  /** ⛔️ Type 'A' is not assignable to type 'A & B' */
  const b: Inv<A & B> = a;
  return b;
};

Same as before in this case, Inv<A> cannot be assigned to Inv<A & B>.

Assignability and Higher-Kinded Types

This principle is relevant to understand the encoding of Higher-Kinded Types in Typescript (from Effect):

export type Kind<F extends TypeLambda, In, Out2, Out1, Target> = F extends {
  readonly type: unknown
}
  ? (F & {
      readonly In: In
      readonly Out2: Out2
      readonly Out1: Out1
      readonly Target: Target
    })["type"]
  : {
      readonly F: F
      readonly In: (_: In) => void // Contravariant
      readonly Out2: () => Out2 // Covariant
      readonly Out1: () => Out1 // Covariant
      readonly Target: (_: Target) => Target // Invariant
    }

In the Effect<R, E, A> the above encoding defines how union (|) and intersection (&) are assignable to each generic parameter.

We are going to learn the details of Higher-Kinded Types and their encoding in Typescript in a follow up article πŸ”œ


That's it!

You can find the full code at this Playground Link.

Now you have a good grasp of variance in Typescript and how it relates to assignability.

These are definitely more advanced concepts, not strictly necessary in your day to day work, but important to understand how Typescript works in all its aspects.

If you are interested in more advanced and beginner content of Typescript you can subscribe to the newsletter below πŸ‘‡

Thanks for reading.

πŸ‘‹γƒ»Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.