Generics allow us to parameterize types, which unlocks great opportunity to reuse types broadly across a TypeScript project.
This is a somewhat abstract concept, so let’s start by grounding ourselves in a practical example.
A motivating use case
In an earlier chapter, we discussed the concept of dictionary data structures that could be typed using index signatures:
tsTry
constphones : {[k : string]: {customerId : stringareaCode : stringnum : string}} = {}phones .home phones .work phones .fax phones .mobile
Let’s take as a given that sometimes it is more convenient to organize collections as key-value dictionaries, and other times it is more convenient to use arrays or lists.
It would be nice to have some kind of utility that would allow us to convert a “list of things into” a “dictionary of things”.
So, let’s treat this array of objects as our starting point:
tsTry
constphoneList = [{customerId : "0001",areaCode : "321",num : "123-4566" },{customerId : "0002",areaCode : "174",num : "142-3626" },{customerId : "0003",areaCode : "192",num : "012-7190" },{customerId : "0005",areaCode : "402",num : "652-5782" },{customerId : "0004",areaCode : "301",num : "184-8501" },]
… and this as what we aim to get in the end…
tsTry
constphoneDict = {"0001": {customerId : "0001",areaCode : "321",num : "123-4566",},"0002": {customerId : "0002",areaCode : "174",num : "142-3626",},/*... and so on */}
In the end, we hope to arrive at a solution that will work for any list we wish to transform into an equivalent dictionary — not just this one specific use case.
We will need one thing first — a way to produce the “key” for each
object we encounter in the phoneList
array. To remain flexible, we will
design our function such that whoever is asking for the list-to-dictionary conversion
should also provide a function that we can use to obtain a “key” from each item in the list.
Maybe our function signature would look something like this:
tsTry
interfacePhoneInfo {customerId : stringareaCode : stringnum : string}functionlistToDict (list :PhoneInfo [], // take the list as an argumentidGen : (arg :PhoneInfo ) => string // a callback to get Ids): { [A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value.2355A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value.k : string]:PhoneInfo } {// return a dictionary}
Of course, we will see an error message as things stand right now, because we haven’t implemented the function yet.
This isn’t too difficult to implement. Let’s make
a very specific solution right now with a forEach
function - which we can refactor
and generalize as a next step.
tsTry
functionlistToDict (list :PhoneInfo [], // take the list as an argumentidGen : (arg :PhoneInfo ) => string // a callback to get Ids): { [k : string]:PhoneInfo } {// create an empty dictionaryconstdict : { [k : string]:PhoneInfo } = {}// Loop through the arraylist .forEach ((element ) => {constdictKey =idGen (element )dict [dictKey ] =element // store element under key})// return the dictionaryreturndict }console .log (listToDict (phoneList , (item ) =>item .customerId ))
Click the Try
button for the code snippet above, click “Run”
in the TypeScript playground, and you should see that this solution works
for our specific example.
Now, let’s attempt to generalize this, and make it so that
it works for lists and dictionaries of our PhoneInfo
type,
but lots of other types as well. How about if we replace every
PhoneInfo
type with any
…
tsTry
functionlistToDict (list : any[],idGen : (arg : any) => string): { [k : string]: any } {/// ⬆️ focus here ⬆️// nothing changed in the code belowconstdict : { [k : string]: any } = {}list .forEach ((element ) => {constdictKey =idGen (element )dict [dictKey ] =element })returndict }constdict =listToDict ([{name : "Mike" }, {name : "Mark" }],(item ) =>item .name )console .log (dict )dict .Mike .I .should .not .be .able .to .do .this .NOOOOOOO
Ok, this works at runtime if we test it in the TypeScript playground,
but every item in our dictionary is an any
. In becoming more flexible
and seeking to handle a variety of different items, we essentially
lose all of our helpful type information.
What we need here is some mechanism of defining a relationship between the type of the thing we’re passed, and the type of the thing we’ll return. This is what Generics are all about
Defining a type parameter
Type parameters can be thought of as “function arguments, but for types”.
Functions may return different values, depending on the arguments you pass them.
Generics may change their type, depending on the type parameters you use with them.
Our function signature is going to now include a type parameter T
:
ts
function listToDict<T>(list: T[],idGen: (arg: T) => string): { [k: string]: T } {const dict: { [k: string]: T } = {}return dict}
Let’s look at what this code means.
The TypeParam, and usage to provide an argument type
- <T> to the right of
listToDict
means that the type of this function is now parameterized in terms of a type parameterT
(which may change on a per-usage basis) -
list: T[]
as a first argument
means we accept a list ofT
‘s.- TypeScript will infer what
T
is, on a per-usage basis, depending on what kind of array we pass in. If we use astring[]
,T
will bestring
, if we use anumber[]
,T
will benumber
.
- TypeScript will infer what
Try to convince yourself of these first two ideas with the following much simpler (and more pointless) example:
tsTry
functionwrapInArray <T >(arg :T ): [T ] {return [arg ]}
Note how, in the three wrapInArray
examples below, the <T>
we see in the tooltip above is replaced
by “the type of the thing we pass as an argument” - number, Date, and RegExp:
tsTry
wrapInArray (3)wrapInArray (newDate ())wrapInArray (newRegExp ("/s/"))
Ok, back to the more meaningful example of our listToDict
function:
tsTry
functionlistToDict <T >(list :T [],idGen : (arg :T ) => string): { [k : string]:T } {constdict : { [k : string]:T } = {}returndict }
-
idGen: (arg: T) => string
is a callback that also usesT
as an argument. This means that…- we will get the benefits of type-checking, within
idGen
function - we will get some type-checking alignment between the array and the
idGen
function
- we will get the benefits of type-checking, within
tsTry
listToDict ([newDate ("10-01-2021"),newDate ("03-14-2021"),newDate ("06-03-2021"),newDate ("09-30-2021"),newDate ("02-17-2021"),newDate ("05-21-2021"),],(arg ) =>arg .toISOString ())
One last thing to examine: the return
type. Based on the way we have
defined this function, a T[]
will be turned into a { [k: string]: T }
for any T
of our choosing.
Now, let’s put this all together with the original example we started with:
tsTry
functionlistToDict <T >(list :T [],idGen : (arg :T ) => string): { [k : string]:T } {constdict : { [k : string]:T } = {}list .forEach ((element ) => {constdictKey =idGen (element )dict [dictKey ] =element })returndict }constdict1 =listToDict ([{name : "Mike" }, {name : "Mark" }],(item ) =>item .name )console .log (dict1 )dict1 .Mike constdict2 =listToDict (phoneList , (p ) =>p .customerId )dict2 .fax console .log (dict2 )
Let’s look at this closely and make sure that we understand what’s going on:
- Run this in the TypeScript playground, and verify that you see the logging you should see
- Take a close look at the types of the items in
dict1
anddict2
above, to convince yourself that we get a different kind of dictionary out oflistToDict
, depending on the type of the array we pass in
This is much better than our “dictionary of any
s”, in that we lose no type information as a side effect of going through the list-to-dictionary transformation.