Using the same code with different types and using generics
When we first start developing classes in TypeScript, it is very common for us to repeat the same code again and again, only changing the type that we are relying on. For instance, if we wanted to store a queue of integers, we might be tempted to write the following class:
class QueueOfInt {
private queue : number[]= [];
public Push(value : number) : void {
this.queue.push(value);
}
public Pop() : number | undefined {
return this.queue.shift();
}
}
Calling this code is as easy as this:
const intQueue : QueueOfInt = new QueueOfInt();
intQueue.Push(10);
intQueue.Push(35);
console.log(intQueue.Pop()); // Prints 10
console.log(intQueue.Pop()); // Prints 35
Later on, we decide that we also need to create a queue of strings, so we add code to do this as well:
class QueueOfString {
private queue : string[]= [];
public Push(value : string) : void {
this.queue.push(value);
}
public Pop() : string | undefined {
return this.queue.shift();
}
}
It is easy to see that the more code we add like this, the more tedious our job becomes and the more error-prone. Suppose that we forgot to put the shift operation in one of these implementations. The shift operation allows us to remove the first element from the array and return it, which gives us the core behavior of a queue (a queue operates as First In First Out (or FIFO)). If we had forgotten the shift operation, we would have implemented a stack operation instead (Last In First Out (or LIFO)). This could lead to subtle and dangerous bugs in our code.
With generics, TypeScript provides us with the ability to create something called a generic, which is a type that uses a placeholder to denote what the type is that is being used. It is the responsibility of the code calling that generic to determine what type they are accepting. We recognize generics because they appear after the class name inside <>, or after things such as method names. If we rewrite our queue to use a generic, we will see what this means:
class Queue<T> {
private queue : T[]= [];
public Push(value : T) : void {
this.queue.push(value);
}
public Pop() : T | undefined {
return this.queue.shift();
}
}
Let's break this down:
class Queue<T> {
}
Here, we are creating a class called Queue that accepts any type. The <T> syntax tells TypeScript that, whenever it sees T inside this class, it refers to the type that is passed in:
private queue : T[]= [];
Here is our first instance of the generic type appearing. Rather than the array being fixed to a particular type, the compiler will use the generic type to create the array:
public Push(value : T) : void {
this.queue.push(value);
}
public Pop() : T | undefined {
return this.queue.shift();
}
Again, we have replaced the specific type in our code with the generic instead. Note that TypeScript is happy to use this with the undefined keyword in the Pop method.
Changing the way we use our code, we can now just tell our Queue object what type we want to apply to it:
const queue : Queue<number> = new Queue<number>();
const stringQueue : Queue<string> = new Queue<string>();
queue.Push(10);
queue.Push(35);
console.log(queue.Pop());
console.log(queue.Pop());
stringQueue.Push(`Hello`);
stringQueue.Push(`Generics`);
console.log(stringQueue.Pop());
console.log(stringQueue.Pop());
What is particularly helpful is that TypeScript enforces the type that we assign wherever it is referenced, so if we attempted to add a string to our queue variable, TypeScript would fail to compile this.
We aren't limited to just having one type in the generic list. Generics allow us to specify any number of types in the definition as long as they have unique names, as follows:
function KeyValuePair<TKey, TValue>(key : TKey, value : TValue)
What happens if we want to call a particular method from our generic? As TypeScript expects to know what the underlying implementation of the type is, it is strict about what we can do. This means that the following code is not acceptable:
interface IStream {
ReadStream() : Int8Array; // Array of bytes
}
class Data<T> {
ReadStream(stream : T) {
let output = stream.ReadStream();
console.log(output.byteLength);
}
}
As TypeScript cannot guess that we want to use the IStream interface here, it is going to complain if we try to compile this. Fortunately, we can use a generic constraint to tell TypeScript that we have a particular type that we want to use here:
class Data<T extends IStream> {
ReadStream(stream : T) {
let output = stream.ReadStream();
console.log(output.byteLength);
}
}
The <T extends IStream> part tells TypeScript that we are going to use any class that is based on our IStream interface.
To see this in action, we are going to create two classes that implement IStream:
class WebStream implements IStream {
ReadStream(): Int8Array {
let array : Int8Array = new Int8Array(8);
for (let index : number = 0; index < array.length; index++){
array[index] = index + 3;
}
return array;
}
}
class DiskStream implements IStream {
ReadStream(): Int8Array {
let array : Int8Array = new Int8Array(20);
for (let index : number = 0; index < array.length; index++){
array[index] = index + 3;
}
return array;
}
}
These can now be used as type constraints in our generic Data implementation:
const webStream = new Data<WebStream>();
const diskStream = new Data<DiskStream>();
We have just told webStream and diskStream that they are going to have access to our classes. To use them, we would still have to pass an instance, as follows:
webStream.ReadStream(new WebStream());
diskStream.ReadStream(new DiskStream());
While we declared our generic and its constraints at the class level, we don't have to do that. We can declare finer-grained generics, down to the method level, if we need to. In this case though, it makes sense to make it a class-level generic if we want to refer to that generic type in multiple places in our code. If the only place we wanted to apply a particular generic was at one or two methods, we could change our class signature to this:
class Data {
ReadStream<T extends IStream>(stream : T) {
let output = stream.ReadStream();
console.log(output.byteLength);
}
}