DEV Community

Cover image for Typescript Generics Explained
Muhammad Younus Raza
Muhammad Younus Raza

Posted on

Typescript Generics Explained

I recently wrote a blog on this topic that received quite a good response Understanding Generics In Typescript.

However, I believe I failed to convey basic concepts to beginners. I agree 100% that this is quite a confusing topic considering its syntax and peculiar structure. Still, it’s a building block in this programming language, and without it, TypeScript wouldn’t be as dynamic.

In this blog, I will start from scratch so that everyone can benefit from it. I would highly appreciate any suggestions regarding my writing, as I am quite new and still have a lot to learn in terms of writing.

So let’s get started

First, understand why we need generics in typescript.

Suppose we need a function that will console anything passed to it. It a weird example but will work for us.

function consoleing(data){
    console.log(data)
}

consoleing("hello");  // hello
consoleing("hello world"); // hello world
consoleing("hello typescript") // hello typescript
Enter fullscreen mode Exit fullscreen mode

Now above is a valid Javascript function but we have not defined types in it so the typescript compiler will give a warning. So let’s add some type.

function consoleing(data:string){
    console.log(data)
}

consoleing("hello");  // hello
consoleing("hello world"); // hello world
consoleing("hello typescript") // hello typescript

consoleing(123) // typescript will gave error
Enter fullscreen mode Exit fullscreen mode

Now, the issue is deciding what type we should add. We could use a Union type like (string | number | boolean), but there are many types, and users can pass any. If we adopt this union pattern, our code will be limited to only string, number, and boolean

Regarding Unions in TypeScript: Will Make a Separate Blog

So now, what is the solution? Type is unknown to use while making a function, and we need to make the function flexible to any type that will be passed at the time of function calling.

The answer is **Generics **let’s look at how it will help us here.

function consoleing<T>(data:T){
    console.log(data)
}

consoleing<string>("hello");  // hello
consoleing<string>("hello world"); // hello world
consoleing<string>("hello typescript") // hello typescript
consoleing<number>(123) // 123
Enter fullscreen mode Exit fullscreen mode

Now, look above the code and try to understand what happened.

We use <> to accept a type, just as we use () to accept function parameters. Similar to normal parameters in JavaScript, generics are used <> to accept type parameters in typescript generics.

Now, we have specified that the type parameter passed is the type this function will accept in the data parameter.

While calling that function, we pass data as a normal parameter but also the type that this function is receiving, which is actually the data type of the data parameter.

With this, we have made our function dynamic for any parameter as well as any type, and we do not have to create a separate function for each type or list down each type in union format.

type userData={
  name:string,
  email:string
}

function consoleing<T>(data:T){
    console.log(data)
}

consoleing<userData>({
  name: "Younus",
  email: "younus@gmail.com",
});  // {
  name: "Younus",
  email: "younus@gmail.com",
}
Enter fullscreen mode Exit fullscreen mode

Now our function is capable of accepting complex data types and it will not give an error.

Just to reiterate, as we use parameters to dynamically pass data into functions in any programming language. In TypeScript, we use generics to pass types at call time. This is the way I remembered it when I was first learning, and, actually, that is the fundamental concept behind it.

Let us look into more examples and use cases of Generics.

Using Generics with Interfaces

we all know what interfaces are, in short, it's a blueprint to define object data type.

Using a simple interface:

interface sampleData{
  name: string
  email: string
}

const ob<sampleData>={
  name : "Younus Raza",
  email : "younus@gmail.com"
}
Enter fullscreen mode Exit fullscreen mode

// now in ob variable we have to follow rules define in initerface

Using interface with generics.

interface Box<T> {
    value: T;
}

// Usage
const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: "Hello, TypeScript!" };

console.log(numberBox); // Output: { value: 42 }
console.log(stringBox); // Output: { value: 'Hello, TypeScript!' }
Enter fullscreen mode Exit fullscreen mode

You can see the syntax is the same with <>. However, when we use an interface, we will pass the type from there using <>, just like in functions where we use () and here, we use <>.

Let us see a little harder one:

// Harder Example with Interface

interface Repository<T> {
    getAll(): T[];
    getById(id: number): T | undefined;
    add(item: T): void;
    update(id: number, newItem: T): void;
    remove(id: number): void;
}

// Usage (an example with User objects)
class UserRepository implements Repository<User> {
    private users: User[] = [];

    getAll(): User[] {
        return this.users;
    }

    getById(id: number): User | undefined {
        return this.users.find(user => user.id === id);
    }

    add(user: User): void {
        this.users.push(user);
    }

    update(id: number, newUser: User): void {
        const index = this.users.findIndex(user => user.id === id);
        if (index !== -1) {
            this.users[index] = newUser;
        }
    }

    remove(id: number): void {
        this.users = this.users.filter(user => user.id !== id);
    }
}

// Example User class
class User {
    constructor(public id: number, public name: string) {}
}

// Usage
const userRepo = new UserRepository();
userRepo.add(new User(1, "Alice"));
userRepo.add(new User(2, "Bob"));

console.log(userRepo.getAll()); // Output: [User { id: 1, name: 'Alice' }, User { id: 2, name: 'Bob' }]
console.log(userRepo.getById(1)); // Output: User { id: 1, name: 'Alice' }
userRepo.update(2, new User(2, "Charlie"));
console.log(userRepo.getAll()); // Output: [User { id: 1, name: 'Alice' }, User { id: 2, name: 'Charlie' }]
userRepo.remove(1);
console.log(userRepo.getAll()); // Output: [User { id: 2, name: 'Charlie' }]
Enter fullscreen mode Exit fullscreen mode

Now, the above code might seem a little intimidating, but if you look closely, it’s just a class. The catch here is when defining an interface, we are not specifying what type of data it will accept.

When we are creating the UserRepository class and using an interface there with the extend keyword, that is where we define it will accept a User class instance. So now, we cannot pass anything into that UserRepository class; instead, only a User class instance is valid.

Believe me, I wasn’t planning to add a complex example, but this is the way you will understand its use cases in real-life scenarios.

Let us look at one more example.

function extractObjectProperties<T, K extends keyof T>(obj: T, keys: K[]): Partial<T> {
  let result: Partial<T> = {};

  for (const key of keys) {
    if (key in obj) {
      result[key] = obj[key];
    }
  }

  return result;
}

const language = {
  name: "TypeScript",
  age: 8,
  extensions: ['ts', 'tsx']
};

const selectedProperties = extractObjectProperties(language, ['name', 'age']);
console.log(selectedProperties);
Enter fullscreen mode Exit fullscreen mode

Now let us understand this.

Here you can see while calling the function we are not using <> to define the type of T and K.

It is because when we call the function it will map the passed value with type by itself and if not able to do that then will through an error.

In this example, it will work fine.

We pass a language object that will map to T, meaning T will be an object with following properties:

const language = {
  name: "TypeScript",
  age: 8,
  extensions: ['ts', 'tsx']
};
Enter fullscreen mode Exit fullscreen mode

And K will be keyof T, meaning it can only be one of the keys of the object T, which, in this case, is the language object, with keys such as name, age, and extensions. No other keys are allowed.

Now, looking into the logic, we know it will return an object similar to T. However, it is possible that not all keys will be present. If we pass only one key as K, then the returned object will have only that key. So, here the return type is Partial, indicating that it will return a T object, but not necessarily with all keys. Partial is built in utility type. I will try to make ablog on it as well.

Now, for beginners, I believe understanding the above and trying to implement and practice it will be beneficial. There are many cases of generics that are much harder, and we will cover them in upcoming blogs.

Conclusion
In simple terms, generics in TypeScript, allow you to create flexible and reusable functions or data structures that can work with different types. It’s like a blueprint that adapts to various data shapes, providing better code safety and reducing redundancy. Generics enable you to write more versatile and adaptable code, making your programs more scalable and easier to maintain.

If you found this content valuable, I’d love to connect with you! Feel free to follow me on LinkedInfor more updates, connect with me on GitHubto explore my projects, and find me on Mediumfor regular insights. Your support means a lot — consider buying me a coffee on Buy Me a Coffee to help fuel more content creation. Looking forward to connecting with you!

Top comments (0)