DEV Community

Cover image for Deeper Dive Into Generics In Type Script
Akpevwe11
Akpevwe11

Posted on • Updated on

Deeper Dive Into Generics In Type Script

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:

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 });
Enter fullscreen mode Exit fullscreen mode

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]

Enter fullscreen mode Exit fullscreen mode

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

we can also call this function as follows:

identity<string>("string");
Enter fullscreen mode Exit fullscreen mode

Here, we are using what is known as type casting that is, the angled brackets 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:

identity<string>(1);
Enter fullscreen mode Exit fullscreen mode

Here, we are explicitly specifying that the function will be called with a type , 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'
Enter fullscreen mode Exit fullscreen mode

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 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:


 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

Enter fullscreen mode Exit fullscreen mode

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:

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");

Enter fullscreen mode Exit fullscreen mode

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:

  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...

Enter fullscreen mode Exit fullscreen mode

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:

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

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

Enter fullscreen mode Exit fullscreen mode

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:

 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

Enter fullscreen mode Exit fullscreen mode

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:

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

Enter fullscreen mode Exit fullscreen mode

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:

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

Enter fullscreen mode Exit fullscreen mode

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 thegetValue method.

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

Top comments (0)