DEV Community

loading...
Cover image for Shrödinger's cat explains TypeScript<Generics>

Shrödinger's cat explains TypeScript<Generics>

aprillion profile image Peter Hozák ・5 min read

In 1935, Erwin complained to Albert about a problem in Quantum Mechanics. The bug report is now known as the Schrödinger's cat thought experiment and the fix, to shut up and calculate, is still deployed in production. Many of those calculations involved matrix multiplication of multidimensional vectors and that took a lot of space on blackboards. Thus, Paul established the ⟨bra|ket⟩ notation to help the cool kids make fewer mistakes.

In 2013, library authors complained to Jonathan about a problem in TypeScript. They wanted to make their utilities more reusable, without any compromise. So starting with version 0.9, we can now shut up and <Generics>.

Union cats

Let's imagine writing a library, "Quantum Cats," that checks whether a cat is dead or alive:

type Cat = {status: 'dead'|'alive'}

function checkCat(cat: Cat) {
  return cat.status
}

// inferred type: 'dead'|'alive'
const result = checkCat({status: 'alive'})
Enter fullscreen mode Exit fullscreen mode

TS Playground

And for a while, union types will be all that we need.

Generic cats

However, our users expect no cats to be harmed in the process of checking! There is not just one type of cats that are all "dead or alive," we need to distinguish different types of cats:

type Cat<Status> = {status: Status}

function checkCat<Status extends 'dead'|'alive'>(
  cat: Cat<Status>
) {
  return cat.status
}

// inferred type: 'alive'
const result = checkCat({status: 'alive'})
Enter fullscreen mode Exit fullscreen mode

TS Playground

Reading the code

Our users are now happy, but we have to explain what we did to our future selves after lunch. As a first step, let's try to read the code above. Everyone might read it differently, and that's fine, here is just an example:

  • we have a type Cat, that is a generic
    • it takes a parameter, type Status, which is implicitly unknown
    • value of type Cat is an object with one property, status
    • type of that property is Status
  • then, we declare a function checkCat, that has a generic type
    • the function itself takes one parameter, cat
    • the function type takes one generic parameter, type Status
    • Status extends a union of 2 types, both are string literals, namely dead or alive
    • the function parameter cat has a type, which is an instance of a generic that we get by invoking Cat with parameter Status inside the angle brackets
      • note: just like in HTML, we write the angle brackets using less-than and greater-than signs <>, not the Unicode characters ⟨⟩
    • the function contains 1 return statement
    • the return expression is cat dot status
    • the return type is inferred
  • then, we make an assignment to a constant variable result
    • the right-hand side consists of a function call
    • checkCat is called with one argument, an object literal that has one property, status, whose value is a string, alive
    • type of the function is inferred from the argument
    • type of the new variable is also inferred

Understanding generics

Now that we can read the code, what to make of it? Let's think of generics as higher order types, a concept similar to higher order functions (when a function returns a function, or if it takes another function as a parameter to call back):

A generic is a type that accepts one or more types as parameters and it returns a different type based on those.

type Cat<Status> = {status: Status}

type CatAlive = Cat<'alive'> // same as {status: 'alive'}
type CatDead  = Cat<'dead'> // same as {status: 'dead'}
type CatUndefined = Cat<undefined> // {status: undefined}
Enter fullscreen mode Exit fullscreen mode

TS Playground

We can restrict what types to allow in the parameters (extends), and we can provide a default type (<... = default>):

type Cat<Status extends 'dead'|'alive' = 'alive'> = {
  status: Status
}

type CatAlive = Cat // {status: 'alive'}
type CatDead = Cat<'dead'> // {status: 'dead'}
type CatDeadOrAlive = Cat<'dead'|'alive'> // CatDead | CatAlive

// not possible because of type error:
type CatUndefined = Cat<undefined>
Enter fullscreen mode Exit fullscreen mode

TS Playground

We can use generics to define function types:

function checkCat<Status extends 'dead'|'alive'>(
  cat: Cat<Status>
): Status {
  return cat.status
}

const checkCat = <Status extends 'dead'|'alive'>(
  cat: Cat<Status>
) => cat.status
Enter fullscreen mode Exit fullscreen mode

TS Playground

Note: <Status>() => {} will not work in *.tsx files, that is ambiguous with a JSX opening tag, but we can use <Status extends unknown>.

We can use generic instances inside other generics:

type Cat<Status> = {status: Status}

function isCatAlive<C extends Cat<string|undefined>>(
  cat: C
): cat is C & Cat<'alive'> {
  return cat.status === 'alive'
}
function feedCat(cat: Cat<'alive'>) {
  return '😻'
}

// inferred type: {status: string}
const cat = {status: 'alive'}

// @ts-expect-error
// Type 'string' is not assignable to type '"alive"'
feedCat(cat)

if (isCatAlive(cat)) {
  feedCat(cat) // ✔
}
Enter fullscreen mode Exit fullscreen mode

TS Playground

And we can mix and match generics with function overloads for our wildly reusable JavaScript utilities, even if we only know how to type some of our use cases, we don't have to be purrfect:

type Cat<Status = string> = {s: Status}

function checkMany<S1>(cats: [Cat<S1>]): [S1]
function checkMany<S1, S2>(cats: [Cat<S1>, Cat<S2>]): [S1, S2]
function checkMany(cats: Cat[]): string[]
function checkMany(cats: Cat[]) {
  return cats.map(({s}) => s)
}

const result1 = checkMany([{s: 'a'}]) // inferred type: [string]
const result2 = checkMany([ // inferred type: ['d', 'a']
  {s: 'd' as const},
  {s: 'a' as const}
])
const result3 = checkMany([ // inferred type: string[] 🤷‍♂️
  {s: 'a' as const},
  {s: 'a' as const},
  {s: 'a' as const}
])
Enter fullscreen mode Exit fullscreen mode

TS Playground

Quantum cats

So a few feature requests later, out code might look like this:

type Cat<Status = 'dead'|'alive'|undefined> =
  Status extends 'dead' ? {status: Status, bury: () => string}
  : Status extends 'alive' ? {status: Status, feed: () => string}
  : {status: Status}

function checkCat<S extends 'dead'|'alive'>(cat: Cat<S>):
  Cat<S>
function checkCat(cat: Cat<undefined>, probability?: number):
  Cat<'dead'|'alive'>
function checkCat(cat: Cat, probability = 0.5) {
  if (cat.status) {
    return cat
  }
  if (Math.random() < probability) {
    return {status: 'dead', bury: () => '😿'}
  }
  return {status: 'alive', feed: () => '😻'}
}

const quantumCat = {status: undefined}

function simulator() {
  const cat = checkCat(quantumCat)
  return cat.status === 'alive' ? cat.feed() : cat.bury()
}
const multipleWorlds = Array.from(Array(100_000)).map(simulator)
const statistics = multipleWorlds.reduce((acc, result) => {
  acc[result] = acc[result] ? acc[result] + 1 : 1
  return acc
}, {} as Record<string, number>)

console.log(statistics)
Enter fullscreen mode Exit fullscreen mode

TS Playground

We introduced some conditional types and type narrowing to be able to call cat.feed() without TypeScript errors about the foolishness of feeding potentially dead cats. Hopefully, we can still read the code.


👋 I am Peter, a.k.a. Aprillion. I considered transcribing the last code example into English sentences, but that would be worth a separate post (or even a youtube video) if enough people are interested. Please let me know in the comments here or on Twitter.

Discussion (0)

pic
Editor guide