Typescript Generics with
with Examples of Daily Life Use

Mithat Akbulut
7 min readMar 3, 2023

--

Hey there, fellow developers! 👋🏼

Today, we’re going to talk about one of the most powerful and flexible features of TypeScript — generics. If you’re not familiar with generics, don’t worry. In this post, we’ll break down what generics are, how they work, and why they’re so useful 🤝🏼. So sit back, grab a cup of coffee, and let’s dive into the world of TypeScript generics! 🤓

What’s that?

TypeScript Generics is a powerful feature that allows us to write reusable code with more type safety. In TypeScript, Generics allows us to define a type or a function that can work with different data types, without sacrificing type safety 🦾.

For many people, the best way to learn something new is by seeing it in action. Reading about a concept can be helpful, but applying it to a real-world example can help to solidify your understanding and make it feel more tangible. This is especially true when it comes to programming languages and frameworks. Seeing code snippets and examples of how a feature works can help you to not only understand how it works but also how it can be used in practice. Whether you’re learning TypeScript generics or any other programming concept, working through examples can make the learning process more engaging and enjoyable.

Lets get started!

Let’s say you want to write a function that takes an array and returns the first element. This function can work with any type of array, so it’s a perfect candidate for Generics. Here’s an example:

function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}

const numbers = [1, 2, 3, 4];
const firstNumber = getFirstElement(numbers); // 1

const strings = ['foo', 'bar', 'baz'];
const firstString = getFirstElement(strings);

This code snippet demonstrates the use of TypeScript’s generics feature to create a function getFirstElement<T> that can operate on arrays of any type. The function takes an array of type T as input and returns the first element of the array of the same type T. If the array is empty, it returns undefined.

Let’s break down the code and explain it step by step:

function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}

The function getFirstElement is defined with a generic type parameter T in angle brackets <>. This parameter allows the function to work with arrays of any type. The function takes an array arr of type T[] as input, which means it is an array of elements of type T. The function returns the first element of the array, which is also of type T. The return type of the function is defined using the union operator | to indicate that it can return either a value of type T or undefined.

const numbers = [1, 2, 3, 4];
const firstNumber = getFirstElement(numbers); // 1

const strings = ['foo', 'bar', 'baz'];
const firstString = getFirstElement(strings); // 'foo'

Two arrays are declared and initialized with some values. The first array numbers contains numeric values, and the second array strings contains string values. Two variables firstNumber and firstString are defined to store the result of calling the getFirstElement function with the respective arrays as arguments. The first call returns the first element of the numbers array, which is the number 1. The second call returns the first element of the strings array, which is the string 'foo'.

You can also use Generics with classes. Here’s an example of a simple Stack class that can work with any data type:

class Stack<T> {
private items: T[] = [];

push(item: T) {
this.items.push(item);
}

pop(): T | undefined {
return this.items.pop();
}

isEmpty(): boolean {
return this.items.length === 0;
}
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop()); // 3

const stringStack = new Stack<string>();
stringStack.push('foo');
stringStack.push('bar');
stringStack.push('baz');
console.log(stringStack.pop()); // 'baz'

Let’s break down the code and explain it step by step:

class Stack<T> {
private items: T[] = [];

push(item: T) {
this.items.push(item);
}

pop(): T | undefined {
return this.items.pop();
}

isEmpty(): boolean {
return this.items.length === 0;
}
}

The Stack class is defined with a generic type parameter T in angle brackets <>. This parameter allows the class to work with elements of any type. The class contains a private member variable items that is an array of type T[], which means it is an array of elements of type T. The class also contains three methods:

  • The push method takes an item of type T as input and adds it to the end of the items array.
  • The pop method removes and returns the last element of the items array, which is also of type T. The return type of the method is defined using the union operator | to indicate that it can return either a value of type T or undefined.
  • The isEmpty method returns a boolean value indicating whether the items array is empty.
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop()); // 3

const stringStack = new Stack<string>();
stringStack.push('foo');
stringStack.push('bar');
stringStack.push('baz');
console.log(stringStack.pop()); // 'baz'

Two instances of the Stack class are created with specific type arguments: number and string. These instances are stored in the variables numberStack and stringStack, respectively. The push method is called on each instance to add some items to the stack. The pop method is then called on each instance, and the returned value is logged to the console.

Let’s get more serious with the following;

interface Entity {
id: string;
}

class Repository<T extends Entity> {
private items: T[] = [];

add(item: T) {
this.items.push(item);
}

getById(id: string): T | undefined {
return this.items.find(item => item.id === id);
}

getAll(): T[] {
return this.items;
}
}

interface User extends Entity {
name: string;
email: string;
}

const userRepository = new Repository<User>();
userRepository.add({ id: '1', name: 'Alice', email: 'alice@example.com' });
userRepository.add({ id: '2', name: 'Bob', email: 'bob@example.com' });

const user = userRepository.getById('1');
console.log(user); // { id: '1', name: 'Alice', email: 'alice@example.com' }

const allUsers = userRepository.getAll();
console.log(allUsers); // [{ id: '1', name: 'Alice', email: 'alice@example.com' }, { id: '2', name: 'Bob', email: 'bob@example.com }]

It may be a bit more complex, but let’s break down this one too and explain it step by step:

interface Entity {
id: string;
}

The Entity interface is defined with a single property, id, which is a string. This interface serves as a base for the User interface, which is defined later in the code.

class Repository<T extends Entity> {
private items: T[] = [];

add(item: T) {
this.items.push(item);
}

getById(id: string): T | undefined {
return this.items.find(item => item.id === id);
}

getAll(): T[] {
return this.items;
}
}

The Repository class is defined with a generic type parameter T that extends the Entity interface. This ensures that any instance of the class can only operate on entities that have an id property of type string.

The class contains three methods:

  • The add method takes an item of type T as input and adds it to the items array.
  • The getById method takes an id string as input and returns the first item in the items array that has an id property matching the input id. The method returns a value of type T or undefined.
  • The getAll method returns an array of all the items in the items array, of type T[].
interface User extends Entity {
name: string;
email: string;
}

The User interface is defined with three properties: id, name, and email. The interface extends the Entity interface, so any instance of the User interface must have an id property of type string.

const userRepository = new Repository<User>();
userRepository.add({ id: '1', name: 'Alice', email: 'alice@example.com' });
userRepository.add({ id: '2', name: 'Bob', email: 'bob@example.com' });

const user = userRepository.getById('1');
console.log(user); // { id: '1', name: 'Alice', email: 'alice@example.com' }

const allUsers = userRepository.getAll();
console.log(allUsers);
// [{ id: '1', name: 'Alice', email: 'alice@example.com' }, { id: '2', name: 'Bob', email: 'bob@example.com }]

An instance of the Repository class is created with the generic type argument User. This means that the repository can only hold and operate on items of type User, which is an entity with an id property, as well as name and email properties. Two items of type User are added to the repository using the add method.

The getById method is called on the repository with an id string as input, and the returned value is logged to the console. The getAll method is also called on the repository, and the returned array of items is logged to the console.

async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
const data = await response.json();
return data as T;
}

interface Post {
id: number;
title: string;
body: string;
}

const post = await fetchJson<Post>('https://jsonplaceholder.typicode.com/posts/1');
console.log(post); // { id: 1, title: 'sunt aut facere repellat...', body: 'quia et suscipit\nsuscipit recusandae...' }

This code snippet demonstrates the usage of generics in asynchronous function fetchJson. The function uses the Promise<T> type, where T is the type of the response data.

The function takes a url as a parameter, and it returns a promise of type T. It uses the built-in fetch function to make an HTTP request to the provided URL and gets the response object. The response.json() method is then called to convert the response body to a JavaScript object.

Finally, the function returns the object as T. In this case, T is the Post interface, which defines the shape of the response object. This means that when calling fetchJson with the Post type parameter, the returned object will have the same shape as the Post interface.

The code snippet also includes an example of how to use the fetchJson function. It calls the function with the Post type parameter and a URL, then logs the returned post object to the console. The output is an object that matches the Post interface, containing the id, title, and body properties of the fetched post.

I hope this story has been helpful in demystifying TypeScript generics and showing you how they can make your code more flexible, reusable, and type-safe. By using generics, you can write more concise and expressive code that can adapt to different types and use cases. Whether you’re working on a small personal project or a large enterprise application, TypeScript generics are a powerful tool that can save you time and headaches in the long run. So, don’t be afraid to experiment with generics in your own code and see how they can improve your development experience.

Thanks for reading, and happy coding! 😇👨🏻‍💻

--

--