All Articles

Building the Mental Model for Generics

Matt Pocock
Matt PocockMatt is a well-regarded TypeScript expert known for his ability to demystify complex TypeScript concepts.

The TypeScript docs are mostly excellent. The TypeScript handbook is a shining example of what good docs look like. They're precise, well-maintained, well laid-out, and complete.

However, if there's one section could us a rewrite, it would be the section on Generics. This section assumes too much knowledge, teaches things in the wrong order, and misses out key information. The generics section of the TypeScript docs leaves you feeling that generics are complicated and mysterious.

Let's rectify that.

Type Helpers

Look at generics first on the type level. Imagine you have a type like this:

type Name = 'matt'

This is a literal string, matt, expressed in a type alias. This can't be altered later, so it behaves similarly to a const variable declaration in JavaScript:

const name = 'matt' 

Using generics, we can change Name to be a function. Add a type argument to it:

type Name<LastName> = 'matt' 

This adds a parameter to Name, meaning that whenever you use it, you'll need to pass in an argument:

type User = { name: Name<'pocock'> } 

This turns the type from a variable to a function. Now, its equivalent in JavaScript would be:

const name = (lastName) => 'matt' 

Since it's now a function, let's name it something more function-like:

type GetName<LastName> = 'matt' 

LastName is currently unused, so as an example return an object from our type 'function':

type GetNameObject<LastName> = {
  firstName: 'matt'
  lastName: LastName
}

The JavaScript equivalent would be:

const getNameObject = (lastName) => {
  return {
    firstName: 'matt',
    lastName,
  }
}

You can then use our GetNameObject type function to create types, by passing it arguments:

type NameObject = GetNameObject<'pocock'>
// {
//   firstName: "matt";
//   lastName: "pocock";
// }

In Total TypeScript's Type Transformations workshop this pattern is called 'type helpers'. Type helpers are really useful on the type level to create new types to help DRY up your code.

A common example is a Maybe type helper:

type Maybe<T> = T | null | undefined

type MaybeString = Maybe<string>
// string | null | undefined

Generic functions

Now you know how to handle generics on the type level, what about generic functions?

Take a simple example, returnWhatIPassIn:

const returnWhatIPassIn = (input: any) => {
  return input
}

This function won't return whatever you pass in - it'll actually always return any on the type level. This is annoying, because it ruins your autocomplete on the thing you pass in:

const str = returnWhatIPassIn('matt')

str.touppercase() // No error here!

Imagine you wanted to create this on the type level. You'd create a type helper called ReturnWhatIPassIn:

type ReturnWhatIPassIn<TInput> = TInput

type Str = ReturnWhatIPassIn<'matt'>
//   "matt"

At the type level, you're taking in TInput - whatever it is - and returning it.

You can use a very similar syntax to annotate our function:

const returnWhatIPassIn = <TInput>(input: TInput): TInput => {
  return input
}

This is now a generic function. This means that when you call it, the type argument of TInput gets inferred from what you pass in:

const str = returnWhatIPassIn('matt')
// "matt"

Put this in simple terms.

A generic function is a type helper layered on top of a normal function.

This means that when you call the function, you're also passing a type to the 'type helper'.

Build up that function again, piece by piece to make it clearer starting from your JavaScript-only function again:

const returnWhatIPassIn = (input: any) => {
  return input
}

Get the structure of the 'type helper' set up first. You'll need to infer an TInput argument, and return that.

const returnWhatIPassIn = <TInput>(input: any): TInput => {
  return input
}

If you try and call this now, it'll infer what it returns as unknown:

const str = returnWhatIPassIn('matt')
// unknown

You haven't told the type helper which arguments to infer from. You need it to infer the type of TInput from the input argument. Fix that like this:

const returnWhatIPassIn = <TInput>(input: TInput): TInput => {
  return input
}

So inside (input: TInput), you perform a mapping between the runtime argument - input - and the type you want it to infer - TInput.

Summary

This is the right way to think about generics - as a type helper laid over your function, with a mapping between them.

Generics get a lot more complex from here - multiple generics, generic constraints, generics hidden deep within other types - but this mental model stays the same.

Matt's signature

Building the Mental Model for Generics