Generic Types

An exploration of generic types and their many uses in TypeScript

ts
type Hello<String extends string> = `Hello ${String}`
type HelloWorld = Hello<'World'>
type HelloWorld = "Hello World"
type HelloRopats = Hello<'Ropats'>
type HelloRopats = "Hello Ropats"

Table of Contents

What are generic types?

In order to talk about generics, we should first talk about functions.

Functions are awesome, aren't they?

ts
// |--parameters--|
function scream(string: string) {
// |------return------|
return string.toUpperCase()
}
scream('wisety') // WISETY
scream('kempsterrrr') // KEMPSTERRRR
scream('codingwithmanny') // CODINGWITHMANNY

Functions are a foundational building block of code!

They allow us to breathe life into the logic of our system, breaking it apart into more digestible and reusable (and named!) pieces.

ts
interface FormValues {
/** The user's email */
email?: string
/** The user's password */
password?: string
}
 
// https://regex101.com/r/gwj6UD/1
const validEmail = /(\w|[.+])+@\w+\.\w+/
 
const hasLength = (string?: string) => string?.length ?? 0 > 0
const isValidEmail = (string?: string) => !!string && validEmail.test(string)
 
function validateFormValues(values: FormValues) {
if (!hasLength(values.email)) {
return 'email is required'
}
 
if (!hasLength(values.password)) {
return 'password is required'
}
 
if (!isValidEmail(values.email)) {
return `'${values.email}' is not a valid email`
}
}

Functions can serve as a pipeline that values pass through, changing shape as they go.

ts
const scream = (string: string) => string.toUpperCase()
const exclaim = (string: string) => `${string}!!!`
const flipTable = (string: string) => `${string} (ノಠ益ಠ)ノ彡┻━┻`
 
console.log(flipTable(exclaim(scream('pbillingsby'))))
// "PBILLINGSBY!!! (ノಠ益ಠ)ノ彡┻━┻"

Okay, you get it. Functions are incredible and the world needs more of them!

Let's move on to thinking about something entirely different: the type of functions.


All of the functions we've defined so far have types attached to their parameters, but did you know they also have a hidden return type?

Let's rewrite the last example with explicit return types:

ts
// |return|
const scream = (string: string): string => string.toUpperCase()
const exclaim = (string: string): string => `${string}!!!`
const flipTable = (string: string): string => `${string} (ノಠ益ಠ)ノ彡┻━┻`

If a return type isn't defined, TypeScript is smart enough to infer the return type of a function based on the type of the values it returns.

Hover over the function names to see the inferred return type:

ts
const returnsString = () => ''
const returnsNumber = () => 0
const returnsBoolean = () => false
const returnsStringArray = () => ['hello', 'world']
const returnsVoid = () => {}
const returnsHelloWorldOrUndefined = () => {
if (Math.random() > 0.5) {
return 'hello world!'
}
}

Neat!

We can also define function types using the type and interface keywords:

ts
// a `Double` function takes a `number` and returns a `number`
type Double = (n: number) => number
 
// `double` is of type `Double`, so it takes a `number` and returns a `number`
const double: Double = (n) => n * 2

Take a second to notice how similar the type body is to the function body:

ts
Double: (n: number) => number
double: (n) => n * 2

This example exposes us to two important details:

To put it another way: types guide our values and values inform our types, but they're entirely separate systems.

It's an important separation to be aware of! We can easily run into situations where our types are telling us one thing but our values are telling us something entirely different:

ts
const x = {} as any
const y = {}
 
const isPositiveNumber = (number: number) => number > 0
 
isPositiveNumber(x) // this shouldn't be okay, but we used `any`!
isPositiveNumber(y)
Argument of type '{}' is not assignable to parameter of type 'number'.2345Argument of type '{}' is not assignable to parameter of type 'number'.

Let's look at one more example before we get into generics. Imagine a Box type that stores a thing, where thing can be anything.

ts
interface Box {
thing: any
}

Now let's imagine we have a function getThingFromBox that accepts a Box and returns the thing inside of it:

ts
const getThingFromBox = (box: Box) => box.thing
const getThingFromBox: (box: Box) => any

Look at the inferred return type of getThingFromBox. Since we defined the type of thing to any, returning box.thing returns a type of any!

Which is an acceptable situation for our type system. But is it an acceptable situation for the users of Box?

Not so much:

ts
const stringBox: Box = {thing: 'string'}
const string = getThingFromBox(stringBox)
const string: any
 
const numberBox: Box = {thing: 69}
const number = getThingFromBox(numberBox)
const number: any

If we put a string or a number in a Box, we expect to get a string or a number back out of it right?

Well what if we manually add a return type?

ts
const getThingFromBox = (box: Box): string => box.thing
 
const string: string = getThingFromBox({thing: 'hello world!'})
const string: string
const number: number = getThingFromBox({thing: 420})
Type 'string' is not assignable to type 'number'.2322Type 'string' is not assignable to type 'number'.

Okay.

Let's think about what we'd need for this to work:

Okay, it's time to say it: we need generic types!


You're finally ready to learn a little secret: generics are the functions of the type world!

Their syntax is different, but their purpose is the same:

Great! But what does that mean at a practical level?


Returning to our Box example, it means that we can use generic types to enable:

First we'll make Box generic:

ts
// `Box` accepts type parameter `T`
type Box<T> = {
// `thing` is whatever `T` is
thing: T
}
 
const stringBox: Box<string> = {thing: 'thing in a box'}
const numberBox: Box<number> = {thing: 1}
const fnBox: Box<() => void> = {
thing: () => {
console.log('hello from inside a box!')
},
}
const userBox: Box<{name: string}> = {
thing: {name: 'hamza'},
}

Yay! We've enabled Box to know the type of thing it's storing!

Box<T> feels a lot like calling a function! Except it takes and returns a type.

Now, how do we make getThingFromBox aware of the type of Box it receives?

You guessed it: MAKE IT GENERIC

ts
// generic parameter `Thing` defines the type of `thing` within `box`, and the
// return type too
const getThingFromBox = <Thing>(box: Box<Thing>): Thing => box.thing
 
// because `box` is `Box<Thing>`, typescript infers the type of `Thing` from
// the type of the `box` argument
 
// `stringBox` = `Box<string>`
// `Thing` = `string`
getThingFromBox(stringBox)
const getThingFromBox: <string>(box: Box<string>) => string
 
const stringThing = getThingFromBox(stringBox)
const stringThing: string
const numberThing = getThingFromBox(numberBox)
const numberThing: number
const fnThing = getThingFromBox(fnBox)
const fnThing: () => void
const userThing = getThingFromBox(userBox)
const userThing: { name: string; }
 
// we can also provide a type argument if we want
getThingFromBox<string>(stringBox)
getThingFromBox<number>(stringBox)
Argument of type 'Box<string>' is not assignable to parameter of type 'Box<number>'. Type 'string' is not assignable to type 'number'.2345Argument of type 'Box<string>' is not assignable to parameter of type 'Box<number>'. Type 'string' is not assignable to type 'number'.

That's pretty cool isn't it?

We could also enable users to create a Box without having to specify Box<T> directly (type inference) by creating another generic function!

ts
const putAThingInABox = <Thing>(thing: Thing): Box<Thing> => ({thing})
 
const stringThing = putAThingInABox('string in a thing in a box')
const stringThing: Box<string>
const numberThing = putAThingInABox(42)
const numberThing: Box<number>

Wild!

TODO: Write more about what generics are. If you'd like to see that, be sure to let me know on Twitter (@grow_love)!


🧘‍♂️

If you've not experienced generic types before, this might feel a little overwhelming.

And that's totally okay! I've been using generic types for a long time now and I still get lost in them.

Keep learning and playing and practicing! Don't be afraid to ask for help! And most importantly: try and teach what you're learning to others!

Teaching is a great way to discover where your knowledge is limited! And someone out there is a little bit behind you in skill and knowledge and would love to read about what you're learning right now.

If you keep at it, you'll find yourself feeling much more confident with generics in no time.

🧘‍♂️


Why are generic types important?

How do we use generic types?

Generic interfaces

ts
interface Item<Kind extends string> {
// uses the `Kind` param to create a `kind` property
kind: Kind
}
 
interface StringItem extends Item<'String'> {
// `StringItem` has a `kind` property of type `'String'`
string: string
}
 
const stringItem: StringItem = {
kind: 'String',
string: 69,
Type 'number' is not assignable to type 'string'.2322Type 'number' is not assignable to type 'string'.
}
 
interface NumberItem extends Item<'Number'> {
number: number
}
 
const numberItem: NumberItem = {
kind: 'Number',
number: '420',
Type 'string' is not assignable to type 'number'.2322Type 'string' is not assignable to type 'number'.
}

Discriminating unions

Union types with a unique key can be discriminated using that key:

ts
interface ThingA {
kind: 'A'
}
interface ThingB {
kind: 'B'
}
interface ThingC {
kind: 'C'
}
 
type Things = ThingA | ThingB | ThingC
 
declare const thing: Things
 
switch (thing.kind) {
(property) kind: "A" | "B" | "C"
case 'A':
thing
const thing: ThingA
break
case 'B':
thing
const thing: ThingB
break
case 'C':
thing
const thing: ThingC
break
default:
thing
const thing: never
}
 
if (thing.kind === 'A') {
thing
const thing: ThingA
} else if (thing.kind === 'B') {
thing
const thing: ThingB
} else if (thing.kind === 'C') {
thing
const thing: ThingC
} else {
thing
const thing: never
}

Generic types

ts
type NumberToString<Number extends number> = `${Number}`
 
type FourTwentySixtyNine = NumberToString<42069>
type FourTwentySixtyNine = "42069"

Generic functions

Type assertions

ts
function assertString(x: unknown): asserts x is string {
if (typeof x !== 'string') {
throw Error(`expected 'string' but received type '${typeof x}'`)
}
}
 
const thing: unknown = {}
const thing: unknown
 
assertString(thing) // if `thing` is not `string`, throws an error
 
thing
const thing: string

Type predicates

Type predicates are functions that return boolean with a special return type of arg is T, where the type of arg is narrowed to T if the predicate passes.

In this example, we have three type predicates for string, number, and boolean.

ts
const isString = (x: unknown): x is string => typeof x === 'string'
const isNumber = (x: unknown): x is number => typeof x === 'number'
const isBoolean = (x: unknown): x is boolean => typeof x === 'boolean'
 
const thing: unknown = {}
const thing: unknown
 
if (isString(thing)) {
thing
const thing: string
} else if (isNumber(thing)) {
thing
const thing: number
} else if (isBoolean(thing)) {
thing
const thing: boolean
} else {
thing
const thing: unknown
}

The following example is a functional combinatorial logic system (is, and, and or) created using type predicates and type inference.

The resulting types are inferred by and (A & B) and or (A | B).

ts
interface Thing {
id: string
}
 
/** A thing with a `string` */
interface StringThing extends Thing {
string: string
}
 
/** A thing with a `number` */
interface NumberThing extends Thing {
number: number
}
 
/** A thing with a `boolean` */
interface BooleanThing extends Thing {
boolean: boolean
}
 
/**
* Type predicate for `Input` -> `Output` using a `boolean` callback that
* accepts `Input`.
*
* @example
* const isString = is<any, string>(x => typeof x === 'string')
*/
const is =
<Input, Output extends Input>(callback: (input: Input) => boolean) =>
(input: Input): input is Output =>
callback(input)
 
/**
* Type predicate for `Input` -> `A & B` that validates that `input` passes both
* `a` and `a` type predicates.
*/
const and = <Input, A extends Input, B extends Input>(
a: (input: Input) => input is A,
b: (input: Input) => input is B,
) => is<Input, A & B>((input) => a(input) && b(input))
 
/**
* Type predicate for `Input` -> `A | B` that validates that `input` passes
* either `a` or `a` type predicates.
*/
const or = <Input, A extends Input, B extends Input>(
a: (input: Input) => input is A,
b: (input: Input) => input is B,
) => is<Input, A | B>((input) => a(input) && b(input))
 
const isStringThing = is<Thing, StringThing>((thing) => 'string' in thing)
const isNumberThing = is<Thing, NumberThing>((thing) => 'number' in thing)
const isBooleanThing = is<Thing, BooleanThing>((thing) => 'boolean' in thing)
 
declare const thing: Thing
 
if (isStringThing(thing)) {
thing
const thing: StringThing
} else if (isNumberThing(thing)) {
thing
const thing: NumberThing
} else if (isBooleanThing(thing)) {
thing
const thing: BooleanThing
}
 
const isStringAndNumberThing = and(isStringThing, isNumberThing)
const isNumberAndBooleanThing = and(isNumberThing, isBooleanThing)
 
if (isStringAndNumberThing(thing)) {
thing
const thing: StringThing & NumberThing
thing.string
(property) StringThing.string: string
thing.number
(property) NumberThing.number: number
thing.boolean
Property 'boolean' does not exist on type 'StringThing & NumberThing'.2339Property 'boolean' does not exist on type 'StringThing & NumberThing'.
any
} else if (isNumberAndBooleanThing(thing)) {
thing
const thing: NumberThing & BooleanThing
}
 
const isStringOrNumberThing = or(isStringThing, isNumberThing)
const isNumberOrBooleanThing = or(isNumberThing, isBooleanThing)
 
if (isStringOrNumberThing(thing)) {
thing
const thing: StringThing | NumberThing
} else if (isNumberOrBooleanThing(thing)) {
thing
const thing: NumberThing | BooleanThing
}

Type utilities

In the same way that functions can be used to create utility functions that offload complexity from other functions, generic types can be used to create utility types that offload type complexity from other types.

This means we can create types that create new types from other types.

Types that map from type A to type B

Let's say we have enum types Words and Numbers:

ts
enum Words {
One = 'one',
Two = 'two',
Three = 'three',
}
 
enum Numbers {
One = 1,
Two = 2,
Three = 3,
}

We'd like to create a mapping type WordToNumber that maps from Word keys to Number values. Something like this:

ts
type OneWordAsNumber = WordToNumber[Words.One]
type OneWordAsNumber = Numbers.One
ts
// we could create an object type...
type WordToNumber = {
[Words.One]: Numbers.One
[Words.Two]: Numbers.Two
}
 
// but we can't enforce that each `Words` is a valid key
type OneWordAsNumber = WordToNumber[Words.One]
type TwoWordAsNumber = WordToNumber[Words.Two]
type ThreeWordAsNumber = WordToNumber[Words.Three]
Property 'three' does not exist on type 'WordToNumber'.2339Property 'three' does not exist on type 'WordToNumber'.

In order to enforce the rule that WordToNumber uses each Words as a key and that the values are all Numbers, we'll have to call in a helper type!

This type (we'll call it _WordToNumber) accepts an object type parameter and enforces the rules on it while also returning it! We then use that type to create WordToNumber!

ts
// the `extends` on `Map` is what enforces the rules
type _WordToNumber<Map extends {[Word in Words]: Numbers}> = Map
 
// now we get a type error if we miss a key!
type WordToNumber1 = _WordToNumber<{
Type '{ one: Numbers.One; two: Numbers.Two; }' does not satisfy the constraint '{ one: Numbers; two: Numbers; three: Numbers; }'. Property 'three' is missing in type '{ one: Numbers.One; two: Numbers.Two; }' but required in type '{ one: Numbers; two: Numbers; three: Numbers; }'.2344Type '{ one: Numbers.One; two: Numbers.Two; }' does not satisfy the constraint '{ one: Numbers; two: Numbers; three: Numbers; }'. Property 'three' is missing in type '{ one: Numbers.One; two: Numbers.Two; }' but required in type '{ one: Numbers; two: Numbers; three: Numbers; }'.
[Words.One]: Numbers.One
[Words.Two]: Numbers.Two
}>
 
// or if the value is the wrong type!
type WordToNumber2 = _WordToNumber<{
Type '{ one: Numbers.One; two: Numbers.Two; three: Words.Three; }' does not satisfy the constraint '{ one: Numbers; two: Numbers; three: Numbers; }'. Types of property 'three' are incompatible. Type 'Words.Three' is not assignable to type 'Numbers'.2344Type '{ one: Numbers.One; two: Numbers.Two; three: Words.Three; }' does not satisfy the constraint '{ one: Numbers; two: Numbers; three: Numbers; }'. Types of property 'three' are incompatible. Type 'Words.Three' is not assignable to type 'Numbers'.
[Words.One]: Numbers.One
[Words.Two]: Numbers.Two
[Words.Three]: Words.Three
}>

Here's a more complex example with two-way mapping:

ts
enum WeaponType {
Sword = 'sword',
Mace = 'mace',
Bow = 'bow',
}
 
enum DamageType {
Slashing = 'slashing',
Crushing = 'crushing',
Piercing = 'piercing',
}
 
// helper type used to create `WeaponTypeToDamageType`. enforces that shape of
// the provided `Map` is `{[Type in WeaponType]: DamageType}` and returns `Map`
type _WeaponTypeToDamageType<Map extends {[Type in WeaponType]: DamageType}> =
Map
 
/**
* Maps the provided `WeaponType` to the corresponding `DamageType`.
*/
type WeaponTypeToDamageType = _WeaponTypeToDamageType<{
[WeaponType.Sword]: DamageType.Slashing
[WeaponType.Mace]: DamageType.Crushing
[WeaponType.Bow]: DamageType.Piercing
}>
 
// helper type used to create `DamageTypeToWeaponTypes`. enforces that each
// `DamageType` is used as a key and that the value is a `WeaponType`
type _DamageTypeToWeaponTypes<Map extends {[Type in DamageType]: WeaponType}> =
Map
 
/**
* Maps the provided `DamageType` to its corresponding `WeaponType`s
*/
type DamageTypeToWeaponTypes = _DamageTypeToWeaponTypes<{
[DamageType.Slashing]: WeaponType.Sword
[DamageType.Crushing]: WeaponType.Mace
[DamageType.Piercing]: WeaponType.Bow
}>
 
// `WeaponType` -> `DamageType`
type SwordDamageType = WeaponTypeToDamageType[WeaponType.Sword]
type SwordDamageType = DamageType.Slashing
type MaceDamageType = WeaponTypeToDamageType[WeaponType.Mace]
type MaceDamageType = DamageType.Crushing
type BowDamageType = WeaponTypeToDamageType[WeaponType.Bow]
type BowDamageType = DamageType.Piercing
 
// `DamageType` -> `WeaponType`
type SlashingDamageWeapon = DamageTypeToWeaponTypes[DamageType.Slashing]
type SlashingDamageWeapon = WeaponType.Sword
type CrushingDamageWeapon = DamageTypeToWeaponTypes[DamageType.Crushing]
type CrushingDamageWeapon = WeaponType.Mace
type PiercingDamageWeapons = DamageTypeToWeaponTypes[DamageType.Piercing]
type PiercingDamageWeapons = WeaponType.Bow

Create a string type using template literals

ts
/** The number of sides a die has */
type DiceSides = 4 | 6 | 8 | 10 | 12 | 20
 
/**
* Represents a dice roll for damage.
*/
interface DiceRoll {
/** The number of dice to roll */
count: number
/** The number of sides on the dice */
sides: DiceSides
}
 
/**
* A string representation of a `DiceRoll` (`DiceString<2, 4>`-> `2d4`).
*/
type DiceString<
Count extends number,
Sides extends DiceSides,
> = `${Count}d${Sides}`
 
type TwoDFour = DiceString<2, 4>
type TwoDFour = "2d4"
 
type WrongSidesNumber = DiceString<2, 420>
Type '420' does not satisfy the constraint 'DiceSides'.2344Type '420' does not satisfy the constraint 'DiceSides'.
 
/**
* Convert a dice string (`2d4`) into a `DiceRoll` object.
*/
type DiceStringToDiceRoll<String extends string> =
String extends `${infer Count extends number}d${infer Sides extends DiceSides}`
? {count: Count; sides: Sides}
: never
 
type TwoDFourRoll = DiceStringToDiceRoll<'2d4'>
type TwoDFourRoll = { count: 2; sides: 4; }
 
type WrongSides = DiceStringToDiceRoll<'2d420'>
type WrongSides = never