Covariance and Contravariance in Typescript

Jul 2, 2021 02:13 · 866 words · 5 minute read #types #typescript

Covariance and Contravariance in Typescript
Image by Free-Photos from Pixabay

Keep in mind that we’re going to use the following principle throughout the article: we can always substitute a type with its subtype.

Covariance

Suppose we have these simple types:

TypeScript
interface Blog {
  title: string
}

interface BlogWithImage extends Blog {
  img: string
}

We know that BlogWithImage is a subtype of Blog. Passing a BlogWithImage to a function that expects Blog should not make the compiler bark.

TypeScript
function printTitle(blog: Blog) {
  console.log(blog.title)
}


printTitle({ title: 'A' } as Blog) // ✅
printTitle({ title: 'B', img: 'someURL' } as BlogWithImage) // ✅

And the following example should not typecheck:

TypeScript
function printImg(blog: BlogWithImage) {
  console.log(blog.img)
}


printImg({ title: 'A' } as Blog) // ❌
printImg({ title: 'B', img: 'someURL' } as BlogWithImage) // ✅

So from these instances, we can draw a conclusion that BlogWithImage is indeed a subtype of Blog as we can use it as a Blog substitute.

BlogWithImage extends Blog

Contravariance

However, when it comes to functions, things are a bit different. Suppose we have fetchBlog function which takes a callback:

TypeScript
await function fetchBlog(callback: (b: Blog) => void) {
  const blog: Blog = await axios.get('...')

  callback(blog)
}

function printTitle (b: Blog): void {
  console.log(b.title)
}

function printTitleImg (b: BlogWithImage): void {
  console.log(b.title)
  console.log(b.img)
}

fetchBlog(printTitle) // ✅
fetchBlog(printTitleImg) // ❌

Providing printTitleImg will not typecheck and will throw at runtime cause the img field will not be present upon fetching the blog (well, not necessarily. But imagine if it was a nested field). The fact that we can’t substitute (b: Blog) => void with (b: BlogWithImage) => void means that (b: BlogWithImage) => void is NOT a subtype of (b: Blog) => void

What about the other way around?

TypeScript
await function fetchBlogWithImage(callback: (b: BlogWithImage) => void) {
  const blogWI: BlogWithImage = await axios.get('...')

  callback(blogWI)
}

function printTitle (b: Blog): void {
  console.log(b.title)
}

function printTitleImg (b: BlogWithImage): void {
  console.log(b.title)
  console.log(b.img)
}

fetchBlogWithImage(printTitle) // ✅
fetchBlogWithImage(printTitleImg) // ✅

Passing printTitle typechecks and we won’t encounter any runtime error since it only consumes title from the BlogWithImage object and just doesn’t care about the rest. So in this case (blog: BlogWithImage) => void is substitutable with (blog: Blog) => void, thus we can say that (blog: Blog) => void IS a subtype of (blog: BlogWithImage) => void.

(blog: Blog) => void extends (blog: BlogWithImage) => void

When it comes to function arguments, the more general type will always be the subtype of the more specific one. This is the opposite (contra!) of what we usually encounter 🙂

Inferring in Contravariant Position

Suppose we have:

TypeScript
type BlogFn =
  | ((b: { title: string }) => void)
  | ((b: { img: string }) => void)

type WhatAmI = BlogFn extends (b: infer T) => void ? T : never

What will be the resulting type of WhatAmI?

We know that if there’s an expression of A extends B, it’s A that is the subtype and B that is the base type. So in this case, BlogFn is the subtype, and (b: infer T) => void is the base type.

And we know that:

  • { title: string } is more general than { title: string, img: string }
  • { img: string } is more general than { title: string, img: string }

Remember, when it comes to function arguments, the more general type will always be the subtype of the more specific one. Thus it follows that:

  • (b: { title: string }) => void is the subtype of (extends) (b: { title: string, img: string }) => void
  • (b: { img: string }) => void is the subtype of (extends) (b: { title: string, img: string }) => void

From these premises, it makes total sense if infer T should give you { title: string, img: string } as it is the very minimum compatible type for each of the BlogFn constituents: it’s now capable to handle both { title } and { img }. And indeed, the resulting type of WhatAmI is { title: string, img: string } 🙂.


If you look closely, it seems like the compiler converts a union type into an intersection type when inferring in contravariant position.

TypeScript
type BlogFn =
  | ((b: { title: string }) => void)
  | ((b: { img: string }) => void)

type IntersectionType = BlogFn extends (b: infer T) => void
  ? (b: T) => void
  : never
/**
 * `IntersectionType` results in
 *
 * (b: { title: string, img: string }) => void
 */

Interesting! Why don’t we just create a utility type that converts union types into intersection types?! 💡

TypeScript
type U2I<U> =
  (U extends unknown ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

The U extends unknown expression makes sure that we distribute/iterate over the union U. As stated in the documentation, “When conditional types act on a generic type, they become distributive when given a union type.” And the rest is similar to what we have just discussed.

TypeScript
type TitleAndImg = U2I<{ title: strnig } | { img: string }>
// { title: string, img: string }

I hope you find this article useful. Stay well!

Edit on