Deeper Dive Into Generics In Type Script
Tags: programming, typescript, beginners, javascript
Published: 2023-06-23 15:34:18

In my previous article, I we look at an introduction to generics in typescript and we looked at a few examples of how to create a generic function, class, and interface. In this article, we're going to take a deeper dive into the world of generics. 

We're going to look at more advance topics such as:

- Multiple generic types  
- Constraining and using the type T
- Generic constraints and interfaces
- Creating new objects within generics

Let's get started with generics.

TypeScript employs an angled bracket notation along with a type symbol to indicate the use of generic syntax.
 
let say you use the type `T` as the generic type. to specify that is being used as a generic type, it has to be wrapped within angle brackets `<T>`. to indicate that this code is substituting a normal type name with the symbol T.

let use some code samples to make this more clearer:

```ts 
function identity<T>(arg: T) {
  console.log(`typeof T is : ${typeof arg}`);
  console.log(`value is : ${arg}`)
}

// Usage
identity(1);
identity("string");
identity(true);
identity(() => { });
identity({ id: 1 });
```
Here, we are calling the `identity` function with a wide range of values (number, string, boolean).

if you print the output to the console, it will be as follows:

```
value is : 1
typeof T is : string
value is : string
typeof T is : boolean
value is : true
typeof T is : function
value is : () => {}
typeof T is : object
value is : [object Object]

```

As we can see from this output, the` identity` function is indeed working with pretty much every type that we can throw at it.

we can also call this function as follows:
```ts 
identity<string>("string");
```

Here, we are using what is known as type casting that is, the angled brackets <type> is used to explicitly specify what type we are calling this function with.

if you look at the previous way in which we called the `indentity` function, we did not, explicitly set the type using this long form notation, but simply called the 
function with an argument, that is, `identity(1)`. In this instance, TypeScript is inferring the type `T` to be a number.

Note, too, that if we explicitly set the type to be used using this long form notation, type rules will apply for any usage of the type T. Consider the following example:

```ts
identity<string>(1);
```

Here, we are explicitly specifying that the function will be called with a type <string>, but our single argument is actually of type number. This code will generate the following error:

```
error TS2345: Argument of type '1' is not assignable to parameter of type 'string'
```

This error is telling us that we are attempting to call a generic function with the wrong type as an argument, as the type of T was explicitly set.

If we do not explicitly specify what type the generic function
should use, by omitting the <type> specifier, the typescript compiler will infer the type to be used from the type of each argument.

##Multiple Generic Type

Type Script also allows you to define multiple type parameters for classes, interfaces, and functions. Here's an example of using multiple generic type parameters in Type Script:

```ts

 function printPair<T1, T2>(pair: [T1, T2]): void {
  const [first, second] = pair;
  console.log(`First: ${first}, Second: ${second}`);
}

printPair<string, number>(["Hello", 42]); // Output: First: Hello, Second: 42
printPair<number, boolean>([3.14, true]); // Output: First: 3.14, Second: true

```

here, we have a function `printPair` with two generic type parameters, `T1` and `T2`. The function takes an array pair of two elements and logs their values to the console.

When calling the `printPair` function, we specify the types for the generic type parameters within angle brackets (<>). In the first invocation, we pass an array of type `[string, number]` to the function, indicating that the first element is a string and the second element is a number. In the second invocation, we pass an array of type `[number, boolean]`, specifying that the first element is a number and the second element is a boolean.

let see another example where we use multiple generic type parameters in TypeScript:

```ts 
class Pair<T1, T2> {
  private first: T1;
  private second: T2;

  constructor(first: T1, second: T2) {
    this.first = first;
    this.second = second;
  }

  getFirst(): T1 {
    return this.first;
  }

  getSecond(): T2 {
    return this.second;
  }

  setFirst(first: T1): void {
    this.first = first;
  }

  setSecond(second: T2): void {
    this.second = second;
  }
}

const pair1: Pair<string, number> = new Pair("Hello", 42);
const pair2: Pair<number, string> = new Pair(3.14, "World");

```
we define a Pair class with two type parameters, `T1` and `T2`. The class has properties first and second of types `T1` and `T2`, respectively. The constructor and methods of the class are also using the generic type parameters.

We can create instances of the Pair class by specifying the types for the type parameters. In the example, pair1 has a type `Pair<string, number>` representing a pair of a string and a number, while pair2 has a type `Pair<number, string>` representing a pair of a number and a string.


##Constraining the type of T

constraining is the act of limiting the type of T in order to only allow a specific set of types to be used within our generic code.  This helps enforce type safety and provides more control over the types that can be used with a generic type or function. Here are a few ways to apply constraints in TypeScript:

 **Type constraints with extends:**

 You can use the extends keyword to enforce that the generic type `T` must extend a certain type or satisfy specific conditions. For example:

```ts 
  interface Printable {
  print(): void;
}

function printItem<T extends Printable>(item: T): void {
  item.print();
}

class Book implements Printable {
  print(): void {
    console.log("Printing book...");
  }
}

printItem(new Book()); // Output: Printing book...

```

In the example above, the `printItem` function accepts a generic parameter `T` constrained by the Printable interface. This means that `T` must be a type that implements the Printable interface. Therefore, we can pass an instance of the Book class (which implements Printable) to the `printItem` function.

**Using T as a return type:** You can use T as the return type of a function, allowing the caller to determine the specific type returned based on the input. For example:

```ts 
function identity<T>(value: T): T {
  return value;
}

const result = identity("Hello");
console.log(result.toUpperCase()); // Output: HELLO

```

In the above example, the identity function takes a generic parameter T and returns the same value of type T. When calling the function with the string "Hello", the inferred type of result is string, and we can use string-specific methods like `toUpperCase()` on it.

**Using intersection (&) to impose multiple constraints:**

You can apply multiple constraints by using the intersection type operator `(&)`. This ensures that the generic type parameter satisfies all the specified conditions. Here's an example:

```ts 
 interface Printable {
  print(): void;
}

function printItem<T extends Printable & { name: string }>(item: T): void {
  console.log(item.name);
  item.print();
}

class Book implements Printable {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  print(): void {
    console.log("Printing book:", this.name);
  }
}

const book = new Book("The TypeScript Guide");
printItem(book); // Output: The TypeScript Guide \n Printing book: The TypeScript Guide

```
In this case, the `printItem` function expects a generic type T that satisfies two conditions: it extends the Printable interface and has a name property of type string. The Book class meets these requirements, so calling `printItem(book)` successfully prints the name of the book and invokes the print method.

**Using type predicates:** Type predicates allow you to specify custom constraints using type assertions and logical checks. This technique is particularly useful when dealing with union types or runtime validations. Here's an example:

```ts 
function isNumber(value: unknown): value is number {
  return typeof value === "number";
}

function multiplyByTwo<T>(value: T): T | undefined {
  if (isNumber(value)) {
    return value * 2;
  }
  return undefined;
}

console.log(multiplyByTwo(5)); // Output: 10
console.log(multiplyByTwo("Hello")); // Output: undefined

```

In the above example, the `isNumber` function is a type predicate that checks whether the value is a number. The `multiplyByTwo` function uses this type predicate to conditionally multiply the value by two if it's a number. Otherwise, it returns undefined. This allows for type-specific operations while maintaining type safety.

**Using T to define properties or methods:** You can use T to define properties or methods within a class or interface. This allows the class or interface to work with different types based on the actual usage. Here's an example:

```ts 
class Container<T> {
  private value: T;

  constructor(value: T) {
    this.value = value;
  }

  getValue(): T {
    return this.value;
  }
}

const container = new Container<string>("Hello");
console.log(container.getValue()); // Output: Hello

```

In this example, the Container class has a generic type parameter T, and the value property is of type T. We can create an instance of Container with a specific type, in this case, string, and retrieve the stored value using the` getValue` method.


By applying constraints in TypeScript, you can ensure that generic types adhere to specific requirements, leading to more robust and predictable code.