Typescript Generics with
with Examples of Daily Life Use
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 typeT
as input and adds it to the end of theitems
array. - The
pop
method removes and returns the last element of theitems
array, which is also of typeT
. The return type of the method is defined using the union operator|
to indicate that it can return either a value of typeT
orundefined
. - The
isEmpty
method returns a boolean value indicating whether theitems
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 typeT
as input and adds it to theitems
array. - The
getById
method takes anid
string as input and returns the first item in theitems
array that has anid
property matching the inputid
. The method returns a value of typeT
orundefined
. - The
getAll
method returns an array of all the items in theitems
array, of typeT[]
.
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! 😇👨🏻💻