DEV Community

Cover image for Master "Generics" In Typescript🎉
Arafat
Arafat

Posted on

Master "Generics" In Typescript🎉

Generics in Typescript allows you to create a component that can work with different types. This lets users use these components with their specific type.

Before reading this article, please read my article about TS utility types as It will help you to understand this article better.

Table of contents

Here are few possible ways to use Generics in Typescript:

1. Generics with type

The easiest way of using generics is with type. For example:

type MyData<Type> = {
  data: Type
}

type DataAsString =  MyData<string>;
// type DataAsString = {
//    data: string;
// }
Enter fullscreen mode Exit fullscreen mode

Because we passed string as a type parameter, our data will be string type. Likewise, if we pass the number type, our data will be of number type. And that's the beauty of generics. You can work with any type.

type MyData<Type> = {
  data: Type
}

type DataAsString =  MyData<number>;
// type DataAsString = {
//    data: number;
// }
Enter fullscreen mode Exit fullscreen mode

2. Generic functions

As you can pass arguments to a function, you can also pass type arguments to a function. For example:

const fetchData = <Type>(url: string): Promise<Type> => {
    return fetch(url)
}

fetchData<{name: string, age: number}>("/api/books")
  .then(res => {
      console.log(res)
      // res: {name: string, age: number}
  })
Enter fullscreen mode Exit fullscreen mode

We passed {name: string, age: number} as a type argument to the fetchData function, And this type argument tells res what It is supposed to be because this is what's getting typed in Promise<Type>. So now the function fetchData has become a generic function. A generic function is just a normal function but includes a type argument.

If we want to pass another type, our res will replicate that same type.

const fetchData = <Type>(url: string): Promise<Type> => {
    return fetch(url)
}

fetchData<{id: number, email: string}>("/api/books")
  .then(res => {
      console.log(res)
      // res: {id: number, email: string}
  })
Enter fullscreen mode Exit fullscreen mode

3. Generics with built-in functions

You can even use generics with built-in functions. For example, you can create a Set that stores only numbers by using the Set constructor with a type parameter:

const numberSet = new Set<number>();
numberSet.add(1);
numberSet.add(2);
numberSet.add(3);
Enter fullscreen mode Exit fullscreen mode

Similarly, you can create a Map that maps strings to numbers by using the Map constructor with type parameters for the key and value types:

const stringToNumberMap = new Map<string, number>();
stringToNumberMap.set("one", 1);
stringToNumberMap.set("two", 2);
stringToNumberMap.set("three", 3);
Enter fullscreen mode Exit fullscreen mode

4. Inferring the types

If your type argument looks similar to your runtime argument then you don't have to pass a generic type to your function. For example:

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

const result1 = identity<number>(42); // const result1: number
const result2 = identity<string>("hello"); // const result2: string
Enter fullscreen mode Exit fullscreen mode

While the above example looks correct, we can simplify it more:

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

const result1 = identity(42); // const result1: number
const result2 = identity("hello"); // const result2: string
Enter fullscreen mode Exit fullscreen mode

In this case, because we are not passing any type argument, then Typescript will look in the runtime argument to see If It can infer anything from it.
So the return type is inferred from the runtime argument type, and the function returns the same type as it receives.

5. Constraints on type arguments

In our following example, we wanted to be able to use the ReturnType utility type, but the compiler could not prove that every type was a function; it could be a string or number, and as we know, that ReturnType works only with functions, so it warns us that we can’t make this assumption.

type GetPromiseData<T> = Awaited<ReturnType<T>>
// Error: Type 'T' does not satisfy the constraint '(...args: any) => any'

type PromiseResult = GetPromiseData<
  () => Promise<{
    id: string
    email: string
  }>
>
Enter fullscreen mode Exit fullscreen mode

So to make It clear to Tyepscript, We have to say that T is only a function, and we can do this by adding extends (...args: any) => any after the T:

type GetPromiseData<T extends (...args: any) => any> = Awaited<ReturnType<T>>

type PromiseResult = GetPromiseData<
  () => Promise<{
    id: string
    email: string
  }>
>
Enter fullscreen mode Exit fullscreen mode

6. Constraints in functions

In our following example, we wanted to access the .address property of arg, but the compiler could not prove that every type has a .address property, so it warned us that we couldn’t make this assumption.

function myFunc<Type>(arg: Type): Type {
  console.log(arg.address); // Property 'address' does not exist on type 'Type'.
  return arg;
}
Enter fullscreen mode Exit fullscreen mode

To fix this error, we can create an interface with address property in it and extend it with the Type argument:

interface IDetails {
  address: string;
}

function myFunc<Type extends IDetails>(arg: Type): Type {
  console.log(arg.address); // Now we know it has a .address property, so no more error
  return arg;
}
Enter fullscreen mode Exit fullscreen mode

Because the generic function is now constrained, it will no longer work over all types:

myFunc(true);
// error: Argument of type 'boolean' is not assignable to parameter of type 'IDetails'.
Enter fullscreen mode Exit fullscreen mode

Instead, we need to pass in values whose type has address property:

myFunc({ length: 10, address: "Something" });
Enter fullscreen mode Exit fullscreen mode

7. Use as when necessary

Sometimes using as is the best thing you can do when using generics. For example:

const ObjectKeys = <T extends {}>(obj: T): Array<keyof T> => {
  return Object.keys(obj) // Error: Type 'string[]' is not assignable to type '(keyof T)[]'. 
}

const result = ObjectKeys({
  id: 6,
  email: "me@gmail.com"
})
Enter fullscreen mode Exit fullscreen mode

Here we are getting an error because the Object.keys method returns an array of string values, and TypeScript cannot guarantee that the string values returned by Object.keys actually correspond to valid keys of the generic type T.

To fix this error, we can explicitly typecast the Object.keys result to an array of keyof T using the as keyword:

const ObjectKeys = <T extends {}>(obj: T) => {
  return Object.keys(obj) as Array<keyof T>
}

const result = ObjectKeys({
  id: 6,
  email: "me@gmail.com"
})
Enter fullscreen mode Exit fullscreen mode

8. Multiple generics

Sometimes we have to use multiple generics to make sure that we are getting back a certain type. Consider the following example:

function getProperty<T>(obj: T, key: keyof T) {
  return obj[key];
}

let x = { a: 1, b: "b", c: true, d: 4 };

getProperty(x, "a"); // return type: string | number | boolean
Enter fullscreen mode Exit fullscreen mode

While the above example is correct, the problem is that the return type is not explicit. Return type is an union consisting of 3 different types. To fix this problem, we can use multiple generic types:

function getProperty<T, Key extends keyof T>(obj: T, key: Key) {
  return obj[key];
}

let x = { a: 1, b: "b", c: true, d: 4 };

getProperty(x, "a"); // return type: number
Enter fullscreen mode Exit fullscreen mode

In this example, we've added generic type parameter Key to represent the key type of the object T. So now we will get a specific return type only.

function getProperty<T, Key extends keyof T>(obj: T, key: Key) {
  return obj[key];
}

let x = { a: 1, b: "b", c: true, d: 4 };

getProperty(x, "c"); // return type: boolean
Enter fullscreen mode Exit fullscreen mode

9. Defaults in type arguments

We can also use default types in generics. Consider the following example:

const makeSet = <T>() => {
  return new Set<T>()
}

const mySet = makeSet() // const mySet: Set<unknown>
Enter fullscreen mode Exit fullscreen mode

Here we are getting unknown because we didn't pass any type argument to makeSet function. We can solve this problem by either passing a type argument like this, makeSet<number>() or by specifying a default type:

const makeSet = <T = number>() => {
  return new Set<T>()
}

const mySet = makeSet() // const mySet: Set<number>
Enter fullscreen mode Exit fullscreen mode

10. Class Types in Generics

We can also refer to class types by their constructor functions. For example:

function create<Type>(c: { new (): Type }): Type {
  return new c();
}
Enter fullscreen mode Exit fullscreen mode

Here's a breakdown of how the function works:

  • The create function is declared with a type parameter Type, representing the type the function will make.

  • The function takes a single argument c, an object representing a constructor function for the type Type. The argument has the type { new (): Type }, an object type specifying a constructor function that takes no arguments and returns a value of type Type.

  • The new keyword is used to create a new instance of the type Type inside the function by calling the constructor function passed as the argument c. The new keyword creates a further object of the type Type and returns it as the result of the function.

  • The function's return type is specified as Type, which ensures that the function returns an instance of the type specified by the type parameter.

Here's an example of how the create function can be used:

class MyClass {
  constructor(public value: string) {}
}

const instance = create(MyClass);
console.log(instance.value); // Output: undefined
Enter fullscreen mode Exit fullscreen mode

This example defines a simple class, MyClass, with a single property value. We then call the create function, passing the MyClass constructor function as the argument. The create function creates a new instance of MyClass using the constructor function and returns it as an instance of type MyClass.


Conclusion

In conclusion, TypeScript's support for generics provides a powerful tool for writing type-safe and reusable code. By defining generic types and functions, you can create code that works with various types while maintaining strict type-checking.

To make the best use of generics in TypeScript, it's essential to understand how to define and use generic types, specify constraints on generic type parameters, and use type inference to reduce the need for explicit type annotations. Additionally, it's crucial to use generics to maintain good code readability and avoid unnecessary complexity.

When appropriately used, generics can significantly improve the quality and maintainability of your TypeScript code. By taking advantage of TypeScript's powerful type system and the flexibility of generic types, you can create highly reusable and expressive code while still maintaining the strong type safety that TypeScript provides.

Visit:
👨‍💻My Portfolio
🏞️My Fiverr
🌉My Github
🧙‍♂️My LinkedIn

Top comments (2)

Collapse
 
tiagowanke profile image
Tiago Wanke Marques

Great article!
I was wondering if you can help me with a question.
Is it possible to define a generic class where Type must extend a generic interface?
I know that the code bellow doesn't work, but it's something like this:
class MyClass<T extends MyGenericInterface<K, Z>> {

Thanks in advance!

Collapse
 
arafat4693 profile image
Arafat

Sorry sir, I tried many ways to find the solution. Unfortunately could not find it. I will keep looking for the solution. In the meantime, thank you so much for finding the article helpful😊