An exploration of generic types and their many uses in TypeScript
ts
typeHello <String extends string> = `Hello ${String }`typeHelloWorld =Hello <'World'>typeHelloRopats =Hello <'Ropats'>
In order to talk about generics, we should first talk about functions.
Functions are awesome, aren't they?
ts
// |--parameters--|functionscream (string : string) {// |------return------|returnstring .toUpperCase ()}scream ('wisety') // WISETYscream ('kempsterrrr') // KEMPSTERRRRscream ('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
interfaceFormValues {/** The user's email *//** The user's password */password ?: string}// https://regex101.com/r/gwj6UD/1constvalidEmail = /(\w|[.+])+@\w+\.\w+/consthasLength = (string ?: string) =>string ?.length ?? 0 > 0constisValidEmail = (string ?: string) => !!string &&validEmail .test (string )functionvalidateFormValues (values :FormValues ) {if (!hasLength (values .return 'email is required'}if (!hasLength (values .password )) {return 'password is required'}if (!isValidEmail (values .return `'${values .}}
Functions can serve as a pipeline that values pass through, changing shape as they go.
ts
constscream = (string : string) =>string .toUpperCase ()constexclaim = (string : string) => `${string }!!!`constflipTable = (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|constscream = (string : string): string =>string .toUpperCase ()constexclaim = (string : string): string => `${string }!!!`constflipTable = (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
constreturnsString = () => ''constreturnsNumber = () => 0constreturnsBoolean = () => falseconstreturnsStringArray = () => ['hello', 'world']constreturnsVoid = () => {}constreturnsHelloWorldOrUndefined = () => {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`typeDouble = (n : number) => number// `double` is of type `Double`, so it takes a `number` and returns a `number`constdouble :Double = (n ) =>n * 2
Take a second to notice how similar the type
body is to the function body:
ts
Double: (n: number) => numberdouble: (n) => n * 2
This example exposes us to two important details:
type
knows that we receive a number
argument and return a number
,
but it doesn't know anything about the logic behind number
-> number
const
knows that it receives n
and returns n * 2
, but it doesn't
know the types of n
and n * 2
(or well... it does know the types, but only
because Double
knows them)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
constx = {} as anyconsty = {}constisPositiveNumber = (number : number) =>number > 0isPositiveNumber (x ) // this shouldn't be okay, but we used `any`!Argument of type '{}' is not assignable to parameter of type 'number'.2345Argument of type '{}' is not assignable to parameter of type 'number'.isPositiveNumber () y
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
interfaceBox {thing : any}
Now let's imagine we have a function getThingFromBox
that accepts a Box
and
returns the thing
inside of it:
ts
constgetThingFromBox = (box :Box ) =>box .thing
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
conststringBox :Box = {thing : 'string'}conststring =getThingFromBox (stringBox )constnumberBox :Box = {thing : 69}constnumber =getThingFromBox (numberBox )
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
constgetThingFromBox = (box :Box ): string =>box .thing conststring : string =getThingFromBox ({thing : 'hello world!'})constType 'string' is not assignable to type 'number'.2322Type 'string' is not assignable to type 'number'.: number = number getThingFromBox ({thing : 420})
Okay.
Let's think about what we'd need for this to work:
Box
to know the exact type of thing
it's storinggetThingFromBox
to know what type is stored inside of each Box
it
receives, and to return that typeOkay, 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:
Box
to know the specific type of thing
it's storinggetThingFromBox
to know what type is stored inside of each Box
it
receives, and to return that typeFirst we'll make Box
generic:
ts
// `Box` accepts type parameter `T`typeBox <T > = {// `thing` is whatever `T` isthing :T }conststringBox :Box <string> = {thing : 'thing in a box'}constnumberBox :Box <number> = {thing : 1}constfnBox :Box <() => void> = {thing : () => {console .log ('hello from inside a box!')},}constuserBox :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 tooconstgetThingFromBox = <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 )conststringThing =getThingFromBox (stringBox )constnumberThing =getThingFromBox (numberBox )constfnThing =getThingFromBox (fnBox )constuserThing =getThingFromBox (userBox )// we can also provide a type argument if we wantgetThingFromBox <string>(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'.getThingFromBox <number>() stringBox
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
constputAThingInABox = <Thing >(thing :Thing ):Box <Thing > => ({thing })conststringThing =putAThingInABox ('string in a thing in a box')constnumberThing =putAThingInABox (42)
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.
🧘♂️
ts
interfaceItem <Kind extends string> {// uses the `Kind` param to create a `kind` propertykind :Kind }interfaceStringItem extendsItem <'String'> {// `StringItem` has a `kind` property of type `'String'`string : string}conststringItem :StringItem = {kind : 'String',Type 'number' is not assignable to type 'string'.2322Type 'number' is not assignable to type 'string'.: 69, string }interfaceNumberItem extendsItem <'Number'> {number : number}constnumberItem :NumberItem = {kind : 'Number',Type 'string' is not assignable to type 'number'.2322Type 'string' is not assignable to type 'number'.: '420', number }
Union types with a unique key can be discriminated using that key:
ts
interfaceThingA {kind : 'A'}interfaceThingB {kind : 'B'}interfaceThingC {kind : 'C'}typeThings =ThingA |ThingB |ThingC declare constthing :Things switch (thing .kind ) {case 'A':thing breakcase 'B':thing breakcase 'C':thing breakdefault:thing }if (thing .kind === 'A') {thing } else if (thing .kind === 'B') {thing } else if (thing .kind === 'C') {thing } else {thing }
ts
typeNumberToString <Number extends number> = `${Number }`typeFourTwentySixtyNine =NumberToString <42069>
ts
functionassertString (x : unknown): assertsx is string {if (typeofx !== 'string') {throwError (`expected 'string' but received type '${typeofx }'`)}}constthing : unknown = {}assertString (thing ) // if `thing` is not `string`, throws an errorthing
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
constisString = (x : unknown):x is string => typeofx === 'string'constisNumber = (x : unknown):x is number => typeofx === 'number'constisBoolean = (x : unknown):x is boolean => typeofx === 'boolean'constthing : unknown = {}if (isString (thing )) {thing } else if (isNumber (thing )) {thing } else if (isBoolean (thing )) {thing } else {thing }
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
interfaceThing {id : string}/** A thing with a `string` */interfaceStringThing extendsThing {string : string}/** A thing with a `number` */interfaceNumberThing extendsThing {number : number}/** A thing with a `boolean` */interfaceBooleanThing extendsThing {boolean : boolean}/*** Type predicate for `Input` -> `Output` using a `boolean` callback that* accepts `Input`.** @example* const isString = is<any, string>(x => typeof x === 'string')*/constis =<Input ,Output extendsInput >(callback : (input :Input ) => boolean) =>(input :Input ):input isOutput =>callback (input )/*** Type predicate for `Input` -> `A & B` that validates that `input` passes both* `a` and `a` type predicates.*/constand = <Input ,A extendsInput ,B extendsInput >(a : (input :Input ) =>input isA ,b : (input :Input ) =>input isB ,) =>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.*/constor = <Input ,A extendsInput ,B extendsInput >(a : (input :Input ) =>input isA ,b : (input :Input ) =>input isB ,) =>is <Input ,A |B >((input ) =>a (input ) &&b (input ))constisStringThing =is <Thing ,StringThing >((thing ) => 'string' inthing )constisNumberThing =is <Thing ,NumberThing >((thing ) => 'number' inthing )constisBooleanThing =is <Thing ,BooleanThing >((thing ) => 'boolean' inthing )declare constthing :Thing if (isStringThing (thing )) {thing } else if (isNumberThing (thing )) {thing } else if (isBooleanThing (thing )) {thing }constisStringAndNumberThing =and (isStringThing ,isNumberThing )constisNumberAndBooleanThing =and (isNumberThing ,isBooleanThing )if (isStringAndNumberThing (thing )) {thing thing .string thing .number Property 'boolean' does not exist on type 'StringThing & NumberThing'.2339Property 'boolean' does not exist on type 'StringThing & NumberThing'.thing .boolean } else if (isNumberAndBooleanThing (thing )) {thing }constisStringOrNumberThing =or (isStringThing ,isNumberThing )constisNumberOrBooleanThing =or (isNumberThing ,isBooleanThing )if (isStringOrNumberThing (thing )) {thing } else if (isNumberOrBooleanThing (thing )) {thing }
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.
A
to type B
Let's say we have enum
types Words
and Numbers
:
ts
enumWords {One = 'one',Two = 'two',Three = 'three',}enumNumbers {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
typeOneWordAsNumber =WordToNumber [Words .One ]
ts
// we could create an object type...typeWordToNumber = {[Words .One ]:Numbers .One [Words .Two ]:Numbers .Two }// but we can't enforce that each `Words` is a valid keytypeOneWordAsNumber =WordToNumber [Words .One ]typeTwoWordAsNumber =WordToNumber [Words .Two ]typeProperty 'three' does not exist on type 'WordToNumber'.2339Property 'three' does not exist on type 'WordToNumber'.ThreeWordAsNumber =WordToNumber [Words .Three ]
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 rulestype_WordToNumber <Map extends {[Word inWords ]:Numbers }> =Map // now we get a type error if we miss a key!typeType '{ 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; }'.WordToNumber1 =_WordToNumber <{[Words .One ]:Numbers .One [Words .Two ]:Numbers .Two }>// or if the value is the wrong type!typeType '{ 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'.WordToNumber2 =_WordToNumber <{[Words .One ]:Numbers .One [Words .Two ]:Numbers .Two [Words .Three ]:Words .Three }>
Here's a more complex example with two-way mapping:
ts
enumWeaponType {Sword = 'sword',Mace = 'mace',Bow = 'bow',}enumDamageType {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 inWeaponType ]:DamageType }> =Map /*** Maps the provided `WeaponType` to the corresponding `DamageType`.*/typeWeaponTypeToDamageType =_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 inDamageType ]:WeaponType }> =Map /*** Maps the provided `DamageType` to its corresponding `WeaponType`s*/typeDamageTypeToWeaponTypes =_DamageTypeToWeaponTypes <{[DamageType .Slashing ]:WeaponType .Sword [DamageType .Crushing ]:WeaponType .Mace [DamageType .Piercing ]:WeaponType .Bow }>// `WeaponType` -> `DamageType`typeSwordDamageType =WeaponTypeToDamageType [WeaponType .Sword ]typeMaceDamageType =WeaponTypeToDamageType [WeaponType .Mace ]typeBowDamageType =WeaponTypeToDamageType [WeaponType .Bow ]// `DamageType` -> `WeaponType`typeSlashingDamageWeapon =DamageTypeToWeaponTypes [DamageType .Slashing ]typeCrushingDamageWeapon =DamageTypeToWeaponTypes [DamageType .Crushing ]typePiercingDamageWeapons =DamageTypeToWeaponTypes [DamageType .Piercing ]
ts
/** The number of sides a die has */typeDiceSides = 4 | 6 | 8 | 10 | 12 | 20/*** Represents a dice roll for damage.*/interfaceDiceRoll {/** 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`).*/typeDiceString <Count extends number,Sides extendsDiceSides ,> = `${Count }d${Sides }`typeTwoDFour =DiceString <2, 4>typeType '420' does not satisfy the constraint 'DiceSides'.2344Type '420' does not satisfy the constraint 'DiceSides'.WrongSidesNumber =DiceString <2,420 >/*** Convert a dice string (`2d4`) into a `DiceRoll` object.*/typeDiceStringToDiceRoll <String extends string> =String extends `${inferCount extends number}d${inferSides extendsDiceSides }`? {count :Count ;sides :Sides }: nevertypeTwoDFourRoll =DiceStringToDiceRoll <'2d4'>typeWrongSides =DiceStringToDiceRoll <'2d420'>