8. Generics
Creating generic functions
So far in this book I’ve showed you how to use TypeScript to implement a number of object-oriented principles in JavaScript – principles that are generally reserved for statically-typed languages such as C# and Java. But, not only does TypeScript allow you to implement concepts like encapsulation and inheritance of an abstract base class, there is one more powerful feature that both Java and C# offer that TypeScript offers as well, and that is generics.
Generics are a way to create functions and classes that define a behavior that can be reused across many different types while retaining the full information about that type.
For example, take this function, which clones an object using the JSON API to serialize the object to a JSON string and then deserialize it back into a new instance of the same object:
function clone(value) {
let serialized = JSON.stringify(value);
return JSON.parse(serialized);
}
Now, looking at this we can see that if it works as I just described then the return value of this function should return the exact same type of object that passed in. In other words, the return value should be the same type as the “value” object.
However, since the JSON.parse API accepts a string that can represent any type of object, there is absolutely no way for it - or TypeScript - to even guess at what type of object is going to come out when that string is serialized. It could literally be any type at all.
By applying generics to this function we can solve two problems at once: not only can we give TypeScript the type information that it needs in order to determine the return type, we can also reuse this same exact code for any and all types in our application!
The syntax for defining a generic function is pretty straight-forward: you simply add “greater-than” and “less-than” signs right after the function name, but before the parentheses containing the parameter list, and give your generic type parameter a name, like this:
function clone<T>(value) {
What this syntax does is tell TypeScript that you will be referring to a generic type in this function and you’ll be referring to the type by the name “T”. Note that I’m using the name “T” here simply because that’s the name that many people use by convention, but you can feel free to give it any valid variable name that you like, for instance: “X”, or “Tinput”.
With this generic type defined, I can use it throughout this method any place I’d use a regular type name: like on the “value” parameter, for instance:
function clone<T>(value: T) {
Now when I hover over the “value” parameter to see its type information, I can see that TypeScript thinks it’s type is “T”. And, the whole point of using it in this example is to tell TypeScript that the return type will always be the same as the input type, so I’ll specify “T” as the return type as well:
function clone<T>(value: T): T {
What is “T”? Well, it’s the type of the parameter that you pass as the “value”, of course! In other words, “T” is not any specific type at all, but is determined by TypeScript each and every time you call this function, and solely depends on how you use the function…
For example, when I pass in a string as the value…
clone("Hello!")
“T” is the string type.
Or, when I pass in a number…
clone(41)
“T” is the number type.
And, of course, it works with custom types as well:
let todo: Todo = {
id: 1,
name: 'Pick up drycleaning',
state: TodoState.Active
};
clone(todo)
Here’s where it gets interesting though… What’s the return value when I pass in an object literal that has a “name” property?
clone({ name: 'Jess' })
Well, TypeScript is smart enough to tell me that it’s an unnamed type that has a name property! Pretty cool, huh?
Even though this example may seem simple, don’t let it fool you: generics are an incredibly powerful and useful tool in the right situations. So, whenever you see a place in your application that you seem to be copying the same code over and over again, and all you’re doing differently in each version is simply changing which type name you’re using, then that might be a great opportunity to reduce that duplicated code into a single generic function.
Creating generic classes
In the previous section, I introduced you to the concept of generics and showed you how to use them to define the input and output types of a function. But, functions aren’t the only place where generic types can come in handy - you can apply them to classes, too!
In fact, you’ve already seen a generic class in action, you just didn’t realize it at the time. Among other classes, TypeScript treats the built-in JavaScript Array type as a generic class. In other words, in previous chapters I used this syntax to indicate an array of number values:
var array: number[] = [1,2];
But you can also use this syntax as another way of writing the same thing:
var array: Array<Number> = [1,2];
Note that in this example these two syntaxes are actually representing the exact same thing so they are completely functionally equivalent. It just so happens I like to use the first syntax, but as this example proves, you can choose whichever syntax you prefer!
Similar to the generic Array type, one of the best scenarios I’ve seen generics come in handy is the typed Key/Value pair class which contains two properties - a key and a value property, and looks like this:
class KeyValuePair<TKey, TValue> {
constructor(
public key: TKey,
public value: TValue
) {
}
}
Notice how in this class accepts two generic type parameters - TKey, and TValue - each separated by a string. In fact, technically speaking, you can define as many generic types as you need, however you generally don’t tend to go over two.
With this class in place, I can create instances of the key/value pair object using any two types of values I want:
let pair1 = new KeyValuePair(1, 'First');
let pair2 = new KeyValuePair('Second', Date.now());
let pair3 = new KeyValuePair(3, 'Third');
If I hover over each of these instances, I can see that TypeScript is inferring the generic type parameters based on the values that I passed in. For instance, it knows pair1 and pair3 are instances of KeyValuePair<number, string> because I passed in a number and a string value as the first and second parameter for each. Likewise, it knows that pair2 is an instance of KeyValue<string, Date> because I passed in a string value and a Date as the first and second parameters.
If I wanted to, I could explicitly tell TypeScript which types I wanted by applying the same generic type syntax as I did to the class, only instead of the generic type parameter names “Tkey” and “Tvalue” I’ll use the names of the actual types that I want, like this:
let pair1 = new KeyValuePair<number, string>(1, 'First');
let pair2 = new KeyValuePair<string, Date>('Second', Date.now());
let pair3 = new KeyValuePair<number, string>(3, 'Third');
And, look at that - by explicitly defining the types that I intended I actually caught a bug in my code when I defined pair2. Oddly enough, the JavaScript Date.now() function doesn’t actually return a Date object – it returns the number of milliseconds elapsed since 1 January 1970! So, I have to take that number and turn it into a Date and everything is fine again:
let pair2 = new KeyValuePair<string, Date>('Second', new Date(Date.now()));
Now that I have my variables I can begin using them. And, the cool thing is that since the types are determined on the fly and TypeScript will remember what those generic type parameters were through the rest of my code:
pair2.key.[AUTOCOMPLETE]
So when I attempt to access them later I continue to get full type support, like I can see here that I am getting this list of string members on the key value that I had passed as a string.
This kind of approach is one way that you can use generic classes, but it gets even more interesting. Now that I’ve defined a generic Key/Value pair type, I can also define a class that can interact with any instances of that type.
For example, I can create a generic class that iterates through a collection of any type of KeyValuePair objects and prints them out to the console:
class KeyValuePairPrinter<T, U> {
constructor(private pairs: KeyValuePair<T, U>[]) {
}
print() {
for( let p of this.pairs ) {
console.log(`${p.key}: ${p.value}`);
}
}
}
I start by passing in two generic type parameters to the class (just like I did with the KeyValuePair class itself) on line XX, which I call “T” and “U”.
Then, I define a constructor that accepts an array of KeyValuePairs that all share the same two types “T” and “U”.
And in that method I iterate through each KeyValuePair and print out their “key” and “value” properties on line XX.
Even though printing the values to the output may not be too compelling of a scenario, the interesting thing about this example is that I have statically-typed access to the objects “key” and “value” properties - regardless of what types “T” and “U” are - because I know that they are all “KeyValuePair” objects!
Now let’s see it in action:
let printer = new KeyValuePairPrinter([ pair1, pair3 ]);
printer.print();
In this example, I create a new instance of the printer and use it to print out two KeyValuePair objects. Notice that I’m only passing in the pair1 and pair3 variables. This is because they share the same key and value types - in other words, they both have a number for a key and a string for a value. And, because they share those same values, those are the types that TypeScript uses as the types for the “T” and “U” generic type parameters.
In other words, TypeScript now knows that this printer object is an instance of KeyValuePairPrinter<number, string> [hover over printer variable] and therefore only accepts KeyValuePairs with the generic parameters of number and string.
On the other hand, if I tried to pass in “pair2”, TypeScript would give me an error, warning me that pair2 does not share the same type as pair1 and pair3:
let printer = new KeyValuePairPrinter([ pair1, pair2, pair3 ]);
This is because pair2 doesn’t share the same generic type parameters - its key is a string and its value is a Date. And, since it has different generic type parameters it is, for all intents and purposes, a completely different type of object altogether.
If generic classes seem a little overwhelming at first, I don’t blame you - I was a little overwhelmed with them, too. But, they are a great way to group generic methods that all operate on the same types of objects.
And, now that you’ve seen generic classes in action, check out the next section where I’ll show you how to place constraints on your generic types to limit the type parameters that consumers of your generic functions or classes can apply.
Creating generic constraints
In the previous few sections I introduced you to generics and showed you how to apply them to both functions and classes. In this section, I’m going to show you some of the more advanced things that you can do with generic type parameters to control how consumers of your generic functions and classes can use them.
First, let me jump back to an example that I showed a few chapters ago: the “totalLength” function that adds the “length” property of two objects together:
function totalLength(x: { length: number }, y: { length: number }) {
var total: number = x.length + y.length;
return total;
}
As I mentioned when I last showed this example, the way this method is currently implemented there’s nothing stopping me from trying to add the length of a string value to the length of an array value… which makes absolutely no sense:
var length = totalLength('Jess', [1, 2, 3])
However, over the past few sections I showed you how to use generic type parameters to define a type in a generic way and then apply that type throughout your function or class. In other words, I can force consumers of this method to pass in two values of the same type but replacing the anonymous types with generic types:
function totalLength<T>(x: T, y: T) {
var total: number = x.length + y.length;
return total;
}
By doing that, I’ve solved one problem, but I’ve created another: I’ve removed the type information saying that the type T must have a length property on it! Luckily, TypeScript offers the concept of generic constraints that I can apply to my generic type parameters to restrict the kinds of values that satisfy those parameters.
The generic constraint syntax is simple. In fact, it involves a keyword you’ve already seen. In order to constraint the types that satisfy a generic type parameter, I simply follow the parameter with the “extends” keyword, followed by the type describing the constraint that I want to put in place.
For example, before I just added the generic type parameter to the “totalLength” method, it only accepted objects that had a “length” field. To apply that same constraint to the generic type parameter, I simply use the anonymous type again:
function totalLength<T extends { length: number }>(x: T, y: T) {
var total: number = x.length + y.length;
return total;
}
With this constraint in place, I finally have everything I want: a method with code that supports a myriad scenarios, but also enforcing the fact that you can’t add the lengths of two completely unrelated types!
And, of course, I don’t have to use anonymous types as my generic type constraints - any type will do, including interfaces, classes, or even primitive types. For instance, I can move this anonymous type into a formal interface if I wanted to:
interface IHaveALength {
length: number
}
function totalLength<T extends IHaveALength>(x: T, y: T) {
var total: number = x.length + y.length;
return total;
}
Using an interface instead of the anonymous type may be a different syntax, but it works out to be the same exact constraint.
That’s the basic overview of generic type constraints, but there are a few caveats to keep in mind…
First, I have to clarify something: for the past few minutes I’ve been saying that both parameters in this example must be of the same type - type “T”. But that’s not strictly true - they can be any type that is compatible with type T, including those types that inherit from it.
For example, let’s say that I use the method to add two arrays together:
var length = totalLength([1, 2], [1, 2, 3])
That’s fine - nothing new there. But, how about if I create my own custom class that extends the base Array class, like this:
class CustomArray<T> extends Array<T> {
toJson(): string {
return JSON.stringify(this);
}
}
Well, because it inherits from the Array class, it is an instance of the Array class, so it matches the same generic type parameter “T”:
var length = totalLength([1, 2], new CustomArray<number>())
There’s a second caveat with generic type constraints and unfortunately this one’s not as nice a surprise as the fact that I can use inherited types to satisfy the same generic type. In fact, it’s a little bit of the opposite…
Remember in the last section when I created the “KeyValuePairPrinter”?
class KeyValuePairPrinter<T, U> {
constructor(private pairs: KeyValuePair<T, U>[]) {
}
print() {
for ( let p of this.pairs ) {
console.log(`${p.key}: ${p.value}`)
}
}
}
When I showed you that class, did you find it a little awkward that I defined the generic types T and U on the class, and then the only place I used them was to pass them along to define the generic types on the KeyValuePair object? I found it awkward.
Frankly, I would have rather included the KeyValuePair type in the class’s type constraint somehow… Something like this, maybe:
class KeyValuePairPrinter<V extends KeyValuePair<T, U>>
But, if I try to do that, TypeScript tells me that it doesn’t know about the types U and V because they weren’t declared. Ok, that’s fair. So how about this?
class KeyValuePairPrinter<T, U, V extends KeyValuePair<T, U>>
Sadly, that doesn’t work, either, because - as TypeScript reminds me in its warning - you’re not allowed to refer to generic parameters that you define in the same type list… Ah well, I guess the original way was really the best I could’ve done…
However, don’t let these kinds of limitations discourage you from using generics or even generic constraints. Used in the right way on the right problems, generic functions and classes can elevate your code from good to elegant.
And, speaking of elegant code, why don’t you head on to the next chapter where I’ll introduce you to another powerful concept: modules.