Learn TypeScript w/ Mike North

Generics Scopes and Constraints

October 25, 2023

Table of Contents

Now that we have covered the basic use of Generics, let’s layer on two more concepts: how scoping works with type params, and how we can describe type params that have more specific requirement than any.

Generic Constraints

Generic constraints allow us to describe the “minimum requirement” for a type param, such that we can achieve a high degree of flexibility, while still being able to safely assume some minimal structure and behavior for use within the scope that has access to the type param.

Motivating use case

Let’s recall the example we used in our Generics chapter, where we arrived at a generic function that could convert a data structure like this

ts
const phoneList = [
{ 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' },
]
Try

into this

ts
const phoneDict = {
'0001': {
customerId: '0001',
areaCode: '321',
num: '123-4566',
},
'0002': {
customerId: '0002',
areaCode: '174',
num: '142-3626',
},
/*... and so on */
}
Try

Here’s the working code we ended up with:

ts
function listToDict<T>(
list: T[], // array as input
idGen: (arg: T) => string // fn for obtaining item's id
): { [k: string]: T } {
// create dict to fill
const dict: { [k: string]: T } = {}
 
for (let item of list) {
// for each item
dict[idGen(item)] = item // make a key store in dict
}
 
return dict // result
}
Try

Let’s strip away some noise and just study the function signature:

ts
function listToDict<T>(
list: T[],
idGen: (arg: T) => string
): { [k: string]: T } {
return {}
}
Try

In this situation, we ask the caller of listToDict to provide us with a means of obtaining an id, but let’s imagine that every type we wish to use this with has an id: string property, and we should just use that as a key.

How might we implement this without generics?

ts
interface HasId {
id: string
}
interface Dict<T> {
[k: string]: T
}
 
function listToDict(list: HasId[]): Dict<HasId> {
const dict: Dict<HasId> = {}
 
list.forEach((item) => {
dict[item.id] = item
})
 
return dict
}
Try

Great, now let’s implement this with generics:

ts
interface HasId {
id: string
}
interface Dict<T> {
[k: string]: T
}
 
function listToDict<T>(list: T[]): Dict<T> {
const dict: Dict<T> = {}
 
list.forEach((item) => {
dict[item.id] = item
Property 'id' does not exist on type 'T'.2339Property 'id' does not exist on type 'T'.
})
 
return dict
}
Try

The problem here is that T can be anything, potentially including things that don’t have this id: string property. We were able to get away with this in our initial solution (with the idGen function) because listToDict didn’t really do anything with T other than store a reference to it in a dictionary.

Describing the constraint

The way we define constraints on generics is by using the extends keyword.

The correct way of making our function generic is shown in the 1-line change below:

diff
- function listToDict(list: HasId[]): Dict<HasId> {
+ function listToDict<T extends HasId>(list: T[]): Dict<T> {

Note that our “requirement” for our argument type (HasId[]) is now represented in two places:

  • extends HasId as the constraint on T
  • list: T[] to ensure that we still receive an array

T extends vs class extends

The extends keyword is used in object-oriented inheritance, and while not strictly equivalent to how it is used with type params, there is a conceptual connection:

When a class extends from a base class, it’s guaranteed to at least align with the base class structure. In the same way, T extends HasId guarantees that “T is at least a HasId”.

Scopes and TypeParams

When working with function parameters, we know that “inner scopes” have the ability to access “outer scopes” but not vice versa:

js
function receiveFruitBasket(bowl) {
console.log("Thanks for the fruit basket!")
// only `bowl` can be accessed here
eatApple(bowl, (apple) => {
// both `bowl` and `apple` can be accessed here
})
}

Type params work a similar way:

ts
// outer function
function tupleCreator<T>(first: T) {
// inner function
return function finish<S>(last: S): [T, S] {
return [first, last]
}
}
const finishTuple = tupleCreator(3)
const t1 = finishTuple(null)
const t1: [number, null]
const t2 = finishTuple([4, 8, 15, 16, 23, 42])
const t2: [number, number[]]
Try

The same design principles that you use for deciding whether values belong as class fields vs. arguments passed to members should serve you well here.

Remember, this is not exactly an independent decision to make, as types belong to the same scope as values they describe.

Best Practices

  • Define type parameters as simply as possible. Consider the two options for listToDict:
ts
interface HasId {
id: string
}
interface Dict<T> {
[k: string]: T
}
 
function example1<T extends HasId[]>(list: T) {
return list.pop()
(parameter) list: T extends HasId[]
}
function example2<T extends HasId>(list: T[]) {
return list.pop()
(parameter) list: T[]
}
 
class Payment implements HasId {
static #next_id_counter = 1;
id = `pmnt_${Payment.#next_id_counter++}`
}
class Invoice implements HasId {
static #next_id_counter = 1;
id = `invc_${Invoice.#next_id_counter++}`
}
 
const result1 = example1([
const result1: HasId | undefined
new Payment(),
new Invoice(),
new Payment()
])
 
const result2 = example2([
const result2: Payment | Invoice | undefined
new Payment(),
new Invoice(),
new Payment()
])
Try

Compare the types of result1 and result2, and observe that, although both example1 and example2 produce the exact same return value, we’re effectively losing type information because of the way we define our type parameter.



© 2023 All Rights Reserved