0%

Learning TypeScript From Scratch(7)

Generics


Hello world

Let’s first write an identity function, which will return the value that it has recieved.

1
2
3
function identity(arg: any): any {
return arg;
}

It’s an absolutely right answer. However, by using any, we are actually losing the information about what the type was when the function returns. If we passed in a number, the only information we have is that we would recieve a value of type any.

So, we need a way to capture the type of the argument, that’s why we use type variable, a special kind of variable that works on types rather than values.

1
2
3
4
5
6
7
function identity<T>(arg: T): T {
/* ^ return value is of type T
^ arg is of type T
^ refer that T is the type variable
*/
return arg;
}

There are two ways to call the function:

1
2
3
4
5
// Explicitly
let output = identity<string>("myString");

// Use type argument inference
let output = identity("myString");

Working with generic type variables

One thing to notice: make sure the way we use type variable can be applied to every agreeable types.

1
2
3
4
5
6
7
8
9
10
11
// Wrong example
function loggingIdentity<T>(arg: T): T {
console.log(arg.length); // Wrong, cuz property length does not exist on type T
return arg;
}

// Correct example
function loggingIdentity<T>(arg: Array<T>): Array<T> {
console.log(arg.length); // OK
return arg;
}

Generic Types

Let’s explore the type of function with type variables.
First, take a look back at the function it self.

1
2
3
function identity<T>(arg: T): T {
return arg;
}

Then we can write the type of function identity().

1
2
// v1
let myIdentity: <T>(arg: T) => T = identity;

We can also write the generic type as a call signature of an object literal type.

1
2
// v2
let myIdentity: { <T>(arg: T): T } = identity;

Further, we can take the object literal and move it to an interface, to make it a generic interface.

1
2
3
4
5
6
// v3
interface GenericIdentityFn {
<T>(arg: T): T;
}

let myIdentity: GenericIdentityFn = identity;

Sometimes we may want to move the generic parameter to be a parameter of the whole interface. This lets us see what type we’re generic over.

1
2
3
4
5
6
// v4
interface GenericIdentityFn<T> {
(arg: T): T;
}

let myIdentity: GenericIdentityFn<number> = identity;

Understanding when to put the type parameter directly on the call signature and when to put it on the interface itself will be helpful in describing what aspects of a type are generic.

Generic Classes

1
2
3
4
5
6
7
8
9
10
11
12
class GenericClass<T> {
zeroValue: T;
add: (x: T, y: T) => T;
}

let gnumber = new GenericClass<number>();
gnumber.zeroValue = 0;
gnumber.add = (x, y) => (x + y);

let gstring = new GenericClass<string>();
gstring.zeroValue = "";
gstring.add = (x, y) => (x + y);

One thing to notice: generic classes are only generic over their instance side rather than their static side, which means that static members cannot use the class’s type parameter.

Generic Constraints

Back to the above example, sometimes we want to write a generic function that works on a set of types where we have knowledge about what capabilities they have. To do so, we can create an interface that describes the constraint, and then make the type variable extends the interface to denote our constraint.

1
2
3
4
5
6
7
8
9
10
11
12
interface Lengthwise {
length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}

// Then we can use it with constraint
loggingIdentity(3); // Wrong
loggingIdentity({ length: 10, value: 3 }); // OK

Using type params in generic constraints

We can declare a type parameter that is constrained by another type parameter. Take a look at the example below:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}

let x = {
a: 1,
b: 2,
c: 3,
d: 4
};

getProperty(x, "a"); // OK
getProperty(x, "m"); // Wrong, cuz "m" is not assignable to parameter of type "a" | "b" | "c" | "d"

Here we want to get a property from an object and make sure that we’re not accidentally grabbing a property that does not exist on the obj, so we’ll place a constraint between the two types.

Using class types in generic constraints

When creating factories in TypeScript using generics, it is necessary to refer to class types by their constructor functions. For example,

1
2
3
function create<T>(c: { new (): T }): T {
return new c();
}

A more advanced example uses the prototype property to infer and constrain relationships between the constructor function and the instance side of class types.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class BeeKeeper {
hasMask: boolean;
}

class ZooKeeper {
nametag: string;
}

class Animal {
numLegs: number;
}

class Bee extends Animal {
keeper: BeeKeeper;
}

class Lion extends Animal {
keeper: ZooKeeper;
}

function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}

createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;