An exploration of generic types and their many uses in TypeScript
tstypeHello <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.
tsinterfaceFormValues {/** 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.
tsconstscream = (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:
tsconstreturnsString = () => ''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:
tsDouble: (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 -> numberconst 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:
tsconstx = {} 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.
tsinterfaceBox {thing : any}
Now let's imagine we have a function getThingFromBox that accepts a Box and
returns the thing inside of it:
tsconstgetThingFromBox = (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:
tsconststringBox :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?
tsconstgetThingFromBox = (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!
tsconstputAThingInABox = <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.
🧘♂️
tsinterfaceItem <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:
tsinterfaceThingA {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 }
tstypeNumberToString <Number extends number> = `${Number }`typeFourTwentySixtyNine =NumberToString <42069>
tsfunctionassertString (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.
tsconstisString = (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).
tsinterfaceThing {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 BLet's say we have enum types Words and Numbers:
tsenumWords {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:
tstypeOneWordAsNumber =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:
tsenumWeaponType {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'>